Structuring Your Data

Objects Meet Files: Serialization


Learning Objectives

  • You can explain why JSON cannot store a custom object directly.
  • You can write a method that converts an object to a plain dictionary.
  • You can write a function or method that rebuilds an object from a dictionary.
  • You can save and load a list of objects by converting at the file boundary.

The Session class is in good shape: named fields, checked construction, behaviour that travels with the data. But the study log stores its data in a file, and that is where objects and files collide. JSON can store strings, numbers, lists, dictionaries, booleans, and null — and a Session is none of those. This chapter handles the conversion between objects and the plain data a file can hold.

Objects Cannot be Directly Converted to JSON

Hand a Session to json.dump and it refuses:

import json
session = Session("math", 45, "2024-03-10")
with open("test.json", "w") as f:
json.dump(session, f)
# TypeError: Object of type Session is not JSON serializable

This is the same wall the JSON chapter hit with date objects, and for the same reason. JSON’s vocabulary is fixed and small. It has no way to write “a Session object” because Session is a type you invented — it did not exist when JSON was designed, and json.dump has no idea how to render it.

The fix is also the same as before: convert the object into something JSON does understand, save that, and rebuild the object when you read it back. The natural in-between form is a dictionary, because a Session’s fields map exactly onto dictionary keys. This converting-to-and-from-storable-data is called serialization, and it always happens at the boundary between your program’s objects and the outside world of files.

Converting an Object to a Dictionary

Give Session a method that returns a plain dictionary of its fields:

class Session:
def __init__(self, subject, minutes, date):
self.subject = subject
self.minutes = minutes
self.date = date
# Other methods omitted for brevity
def to_dict(self):
return {
"subject": self.subject,
"minutes": self.minutes,
"date": self.date,
}

The method to_dict reads the object’s attributes and packs them into a dictionary whose values are all JSON-friendly types — two strings and a number. That dictionary is something json.dump accepts:

session = Session("math", 45, "2024-03-10")
print(session.to_dict())
# {'subject': 'math', 'minutes': 45, 'date': '2024-03-10'}

It is a method rather than a free-standing function because turning a session into its storable form is behaviour about one session, using only that session’s fields — exactly the test from the methods chapter.

Conversion at the boundary

0 / 5 points

Why does a program usually convert objects to dictionaries before writing JSON?

Rebuilding an Object From a Dictionary

The reverse direction takes a dictionary — the kind json.load produces — and builds a Session from it:

def session_from_dict(data):
return Session(data["subject"], data["minutes"], data["date"])

Given a dictionary, this reads the three fields by key and passes them to Session(...), producing a real object with all its methods:

data = {"subject": "math", "minutes": 45, "date": "2024-03-10"}
session = session_from_dict(data)
print(session) # Math: 45 minutes on 2024-03-10

The file representation is still the dictionary from to_dict, because a display string is not a reliable shape for rebuilding an object later.

This is written as a free-standing function rather than a method: there is no object to call it on yet.

A method needs an instance to be self; here the whole point is to create the instance from raw data, so there is nothing for self to be. Building an object from data is naturally a function that returns a new object.

A round-trip should return what you started with — convert to a dictionary and back, and you have an equal session:

original = Session("math", 45, "2024-03-10")
rebuilt = session_from_dict(original.to_dict())
print(rebuilt.subject, rebuilt.minutes, rebuilt.date)
# math 45 2024-03-10

This round-trip check is the serialization version of the testing habit: convert one way, convert back, confirm you get the same thing. If to_dict and session_from_dict disagree about field names, the round trip breaks and the test catches it.

Ticket serialization round trip

0 / 10 points

Complete the provided Ticket class and ticket_from_dict(data) function.

A ticket has these attributes:

  • title,
  • priority,
  • and created.

Add a method named to_dict(self) that returns a plain dictionary with this exact shape:

{"title": title, "priority": priority, "created": created}

Add a function named ticket_from_dict(data) that takes a dictionary with that shape and returns a Ticket object with the same values.

Requirements:

  • ticket_from_dict(ticket.to_dict()) must rebuild an equivalent ticket,
  • do not change the input dictionary passed to ticket_from_dict,
  • do not print anything,
  • and do not use input(...).

Saving and Loading a List of Objects

The study log holds many sessions, so saving means converting a list of objects into a list of dictionaries, then handing that to json.dump. The conversion is the grouping-by-transformation you have done before — one dictionary per object:

import json
def save_sessions(path, sessions):
"""Save a list of Session objects to a JSON file."""
data = []
for session in sessions:
data.append(session.to_dict())
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)

The loop turns the list of Session objects into a list of plain dictionaries. json.dump then writes that list, which is made entirely of JSON-friendly types, to the file. The objects never touch the file directly — they are converted at the boundary.

Loading reverses the steps: read the list of dictionaries with json.load, then build a Session from each:

import json
def load_sessions(path):
"""Load a list of Session objects from a JSON file. Returns [] for a missing file."""
try:
f = open(path, encoding="utf-8")
except FileNotFoundError:
return []
with f:
data = json.load(f)
sessions = []
for item in data:
sessions.append(session_from_dict(item))
return sessions

The call json.load produces a list of dictionaries; the loop turns each one into a Session. The first-run case — a missing file giving an empty list — is preserved from the JSON chapter. What comes out is a list of real Session objects, each with its methods, ready for the rest of the program to use.

Saving and Loading with a Collection Class

Although the above example demonstrates the serialization pattern, it is not the most natural place for the save and load methods. The StudyLog class from the methods chapter is a natural home for the save and load methods. The log holds a collection of sessions, so it is the right place for methods that operate on the whole collection — including saving and loading. The save method becomes:

class StudyLog:
def __init__(self, sessions):
self.sessions = sessions
def save(self, path):
data = []
for session in self.sessions:
data.append(session.to_dict())
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)

Similarly, the load function becomes:

def load_study_log(path):
"""Load a StudyLog from a JSON file. Returns an empty log for a missing file."""
try:
f = open(path, encoding="utf-8")
except FileNotFoundError:
return StudyLog([])
with f:
data = json.load(f)
sessions = []
for item in data:
sessions.append(session_from_dict(item))
return StudyLog(sessions)

This structure assumes that the log is the only thing in the file — it reads the whole file, builds a log from it, and returns that. If you had multiple things in the file, you would need to write a more complex loader that read them all and built each one appropriately. The pattern, though, is the same: read the file into plain data, then convert that data into the rich objects the program uses.

Attributes can also be updated after an object has been created. The syntax is the same assignment syntax you used inside __init__, just with a real object before the dot:

item.packed = True

The Shape of the Boundary

Step back and look at the overall flow, because the pattern is the important part and it recurs far beyond this project:

file (JSON text)
<-> list of dictionaries (json.load / json.dump)
<-> list of Session objects (session_from_dict / Session.to_dict)
<-> a StudyLog object (load_study_log / StudyLog.save)

Inside the program, you work with StudyLog and Session objects — named fields, methods, the lot. At the boundary with the file, you convert down to plain dictionaries, because that is all JSON can store. The conversion happens in exactly two places: load_study_log on the way in, StudyLog.save on the way out. The objects never touch the file directly — they are converted at the boundary — and everywhere else in the program deals in objects and never thinks about dictionaries or JSON at all.

This separation is worth holding onto. The rich type lives in memory where it helps you; the plain, storable form lives at the edge where the file demands it; and a thin conversion layer keeps the two apart. It is the same idea that the boundary principle highlights — the boundary is where you do the messy work of validating and converting data, so the inside of the program can be clean and focused on its real job.

Check Your Understanding

  1. Why can’t you pass a Session object straight to json.dump?
  2. Why is to_dict written as a method on Session, while session_from_dict is written as a free-standing function?
  3. Why is save a method on StudyLog, while load_study_log is a free-standing function?
  4. What does a round-trip test for serialization check, and why is it worth writing?
  5. In the save and load flow, at which two places does the program convert between objects and dictionaries?
  6. The chapter describes a “boundary” between objects and files. What lives on each side of it, and where does the conversion happen?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0