Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

dataclasses.asdict raises RecursionError? Choose export fields before traversal

A field filter can run too late. Distinguish a recursive type, a real object cycle and a shared child before choosing a JSON representation.

Finite chain, actual back-reference and shared acyclic child compared
Original explanatory illustration: object topology, rather than a recursive annotation alone, determines the tested outcome. Watch at Ease

A dataclass can print neatly and still fail when asdict() follows a child back to its parent. Removing parent from the resulting dictionary cannot help if conversion never reaches that dictionary. Decide which relationships belong in the exported representation before recursive traversal starts.

We ran ten small controls on CPython 3.12.14 on Linux. They separate three object shapes, demonstrate a filter that is never called, and verify a flat ID representation without changing the original parent–child links. This is a bounded diagnostic procedure, not a general graph serializer.

1. Establish the object shape and the failing operation

Record the interpreter version and the full traceback. Locate whether the exception comes from asdict, your own conversion helper or json.dumps. In a disposable example, inspect identities rather than recursively printing an entire graph: child.parent is root proves a particular back-reference, while equal values alone do not.

Our finite Node chain ended in None and converted normally. A root whose children list contained a child pointing back to root raised RecursionError. A Pair whose left and right fields referenced the same leaf had no cycle: asdict succeeded, producing equal but distinct dictionaries. Do not treat every repeated identity as a cycle; the distinction is whether it reappears on the active path.

2. Exclude before descent, not in dict_factory

In the exact tested CPython implementation, field values are recursively converted before the custom dict_factory receives its pairs. Our factory incremented a counter and removed the key parent, but the two-node cycle raised RecursionError while that counter remained zero. Filtering an already constructed result has the same timing problem.

The parent field in another control used repr=False, compare=False and metadata={"serialize": False}; asdict still followed it. The documented flags control generated representation and comparison, while metadata needs a consumer that understands it. The standard asdict function does not adopt an invented serialization flag. Changing those declarations is not a demonstrated export fix.

3. Keep copying separate from serialization policy

The documented shallow extraction, {f.name: getattr(obj, f.name) for f in fields(obj)}, avoids recursive asdict conversion. In our child example, however, the parent value was still the original root object. Passing that dictionary to json.dumps with default=vars raised ValueError for a circular reference. A shallow dictionary is useful for inspection; it is not automatically a JSON tree.

deepcopy(root) created new objects whose child pointed back to the copied root. That is correct graph copying, but JSON encoding with the same diagnostic default still refused the cycle. The original author tutorial Deterministic hashing of Python data objects already demonstrates selective shallow extraction before JSON recursion. The technique is established; the missing decision is which links your export should omit or represent explicitly.

4. Define a small wire schema with stable references

For our two-node example, keep each node once in a nodes array and emit only id, parent_id and child_ids. The root row is {"id":"root","parent_id":null,"child_ids":["child"]}; the child row is {"id":"child","parent_id":"root","child_ids":[]}. These strings preserve the relationship without embedding the parent object again. Use stable application IDs, not process-specific id(obj) values, for a persisted interchange format.

Build those rows directly from an explicit list of nodes, without calling asdict on the cyclic model. Before encoding, reject duplicate IDs and any parent or child ID absent from the declared node set. Our controls rejected both invalid cases. This schema is deliberately narrow: decide separately how your application handles disconnected nodes, inconsistent reciprocal links, extra fields and authorized data exposure; successful encoding does not settle those policies.

5. Verify the representation and preserve the source model

Our valid projection passed json.dumps followed by json.loads, retained two rows and all intended ID links, and left child.parent is root and root.children[0] is child true. Repeat those checks for your own fixture, then validate the receiver’s schema and reference resolution. Keep a negative duplicate-ID case and a missing-reference case so a later refactor cannot silently discard relationships.

Do not raise the recursion limit to make a real cycle serializable or disable circular checks. Dataclass Wizard’s reviewed examples load finite nested dictionaries into recursive type models; those examples alone do not prove that a live identity cycle can be exported. No third-party serializer was executed here. Our results apply to the tested build and ten small controls, not every release or arbitrary object graph.

Parent and child exported once with ID links and validation checks
Original explanatory illustration: define the wire representation before traversing application relationships. Watch at Ease