Skip to content

← Data Modelling and Invariants step 7 of 25

Hard Framework

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, and json.dumps blows up on the half you did not look at.
  • Cycles raise RecursionErrorgh-94345, still open. A RecursionError a thousand frames deep tells you nothing about which field closed the loop.
  • It preserves NamedTuple types (it rebuilds them with type(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: asdict is a dataclass-to-dict function, not a dataclass-to-JSON function. The json.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.