We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 7 of 25
asdict() and astuple(): the deep-copy tax
One word in a serialization path is a 40× multiplier on every response body,
and it is invisible in review. The word is asdict.
What asdict() actually does
It recurses into dataclasses, dicts, lists and tuples — and copy.deepcopy()s
everything else. Not “copies the reference”. Deep-copies. Every datetime,
every Decimal, every UUID, every Path, every Enum member, every object
you own.
3.12 added a fast path, _ATOMIC_TYPES, that skips the copy for
None, bool, int, float, str, complex, bytes, range, type,
property and functions. Look at what is not on that list: datetime, UUID,
Decimal, Path, Enum, and anything from your own domain — i.e. exactly the
field types a real record has.
Measured: 5,000 rows of (UUID, datetime, Decimal, str) →
24.8 ms with asdict() vs 0.6 ms with a hand-written field comprehension.
Three more sharp edges:
-
It does not recurse into
set/frozenset. A set of dataclasses comes back as a set of dataclass instances inside an otherwise-converted dict. Half converted, andjson.dumpsblows up on the half you did not look at. -
Cycles raise
RecursionError— gh-94345, still open. ARecursionErrora thousand frames deep tells you nothing about which field closed the loop. -
It preserves
NamedTupletypes (it rebuilds them withtype(obj)(*converted)) rather than flattening them to lists, which is either a nice touch or a surprise depending on what you expected. -
The output is not JSON-serializable. That is the whole point:
asdictis a dataclass-to-dict function, not a dataclass-to-JSON function. Thejson.dumps(asdict(x), default=str)idiom papers over it — slowly, and lossily.
The Any laundering
asdict is stubbed -> dict[str, Any]. So:
def payload(o: Order) -> dict[str, str]:
return asdict(o) # mypy: no error
Every value in there is Any, so the declared dict[str, str] is accepted, and
every downstream .upper() on an int type-checks too. This is the headline
example of Any leaking through a stdlib boundary — and the reason the function
you are about to write must have a real return type.
Your task
First, complete the recursive PEP 695 alias:
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
Then implement to_jsonable(obj: object) -> JsonValue:
| input | output |
|---|---|
None, bool, int, float, str |
itself |
Enum member |
its .value, converted |
Decimal |
str(value) — not float, which loses precision |
datetime |
.isoformat() |
UUID |
str(value) |
Mapping |
a dict with str keys, values converted |
set / frozenset |
a list, sorted by repr so the output is deterministic |
list / tuple |
a list, converted |
| dataclass instance |
a dict of {field name: converted value}, in field order |
| anything else |
TypeError |
No deep copies anywhere. Track visited objects by id() along the current
path and raise ValueError(f"cycle detected at {type(obj).__name__}") the second
time you meet one — a precise, named error instead of a RecursionError.
Order matters. bool before int is free (both are returned as-is), but Enum
must be checked before the scalar branch if your enum has int values, and
Mapping must be checked before the sequence branch. Put the dataclass branch
last so a NamedTuple still converts as a sequence.
solve(fixture) builds the named fixture and returns {"error", "json"}:
{"error": "", "json": <converted>} on success, or
{"error": <the ValueError message>, "json": None} on a cycle.
On the seen-set. Pass a new set down each branch rather than mutating one
shared set. A dict that references the same Address twice is a DAG, not a
cycle, and must serialise fine — mutating a single shared set would reject it.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.