Batu Lab NotesPractical developer guides

Project a cyclic dataclass graph without calling asdict recursively

By Batu · English technical notes

A cyclic dataclass graph needs an application-defined projection before serialization. dataclasses.asdict() recursively converts dataclass fields and nested containers, but it has no cycle-marker policy. When a Node points to itself through child, the attempted asdict(root) follows that relationship until Python raises RecursionError. The example catches the failure solely to contrast it with a controlled diagnostic projection.

The replacement tracks private object identities in seen during the traversal. On revisiting an identity, it emits the public value "<cycle>"; numeric object IDs are never included in output. The separate max_depth condition handles an acyclic graph that is deeper than the desired diagnostic representation. Removing an identity in finally is important: a later sibling may legitimately reference the same object without being a cycle on that sibling’s current path.

This particular projector intentionally preserves only name and child. It is not a general replacement for asdict, a deep-copy promise, or a JSON schema. Select fields, cycle markers, and depth policy for the application that consumes the projection. The example requires Python 3.7+ because dataclasses and asdict were introduced then.

AI assistance disclosure: this article was drafted with AI using one in-memory synthetic node.

Source: Python dataclasses documentation.

from dataclasses import asdict, dataclass


@dataclass
class Node:
    name: str
    child: object = None


def project(node, seen=None, depth=0, max_depth=3):
    if seen is None:
        seen = set()
    if node is None:
        return None
    if id(node) in seen:
        return "<cycle>"
    if depth >= max_depth:
        return "<max-depth>"
    seen.add(id(node))
    try:
        return {
            "name": node.name,
            "child": project(node.child, seen, depth + 1, max_depth),
        }
    finally:
        seen.remove(id(node))


root = Node("root")
root.child = root
try:
    asdict(root)
except RecursionError:
    print("asdict raises RecursionError")

projection = project(root)
assert projection == {"name": "root", "child": "<cycle>"}
print(projection)
asdict raises RecursionError
{'name': 'root', 'child': '<cycle>'}