backend / python core / tricky questions / 37_self_referential_dict.md

Self-referential dicts (and lists)

2 min read source

Self-referential dicts (and lists)

The gotcha

A dict (or list) can contain itself as a value. Python’s repr detects the cycle and prints {...} to avoid infinite recursion. The cycle is real, though — and naive code (json.dumps, copy.deepcopy without care, custom serializers) can blow up.

Minimal repro

d = {}
d['self'] = d
print(d)           # {'self': {...}}

l = []
l.append(l)
print(l)           # [[...]]

repr traversal tracks visited container IDs and prints ... when it revisits one. So you get a finite (if odd) string instead of a stack overflow.

What about traversal?

d = {}
d['self'] = d
for k, v in d.items():
    if isinstance(v, dict):
        for k2, v2 in v.items():     # infinite loop incoming
            ...

Manual traversal must track visited objects:

def walk(obj, seen=None):
    seen = seen or set()
    if id(obj) in seen:
        return
    seen.add(id(obj))
    if isinstance(obj, dict):
        for k, v in obj.items():
            walk(v, seen)

Standard library handling

Function Behavior on cycle
repr(d) prints {...} for the cycle
str(d) same as repr
copy.copy(d) shallow — copy keeps the cycle (still self-referential)
copy.deepcopy(d) tracks memo, handles cycles correctly
json.dumps(d) ValueError: Circular reference detected
pickle.dumps(d) works — pickle handles cycles via memoization
pprint.pprint(d) shows {...} like repr
import json, copy, pickle

d = {}
d['self'] = d

copy.deepcopy(d)        # OK — produces a new dict that's also self-referential
pickle.dumps(d)         # OK — bytes encode the cycle
json.dumps(d)           # ValueError: Circular reference detected

Memory and garbage collection

Self-references defeat reference counting. CPython’s primary GC is reference counting; cyclic GC catches the rest.

import gc
d = {}
d['self'] = d
del d                   # refcount goes from 2 to 1 (self-ref still holds)
                        # cyclic GC needs to run to actually free
gc.collect()            # forces it

Cyclic GC kicks in periodically (after a threshold of new objects). For long-running services with deliberate cycles, this is fine. For “I created it then dropped it” patterns, the memory holds until the next GC sweep.

__del__ on objects in cycles is special: in Python 3.4+ (PEP 442), cycles with __del__ are collectible. Before 3.4, they leaked.

See 02_python_core/27_memory_model.md, 14_id_reuse_after_gc.md.

When self-references happen accidentally

Dataclass with parent pointer:

@dataclass
class Tree:
    children: list
    parent: 'Tree' = None

root = Tree(children=[])
child = Tree(children=[], parent=root)
root.children.append(child)     # cycle: root → child → root

Serializing root to JSON fails. Detach the parent pointer or use IDs:

@dataclass
class Tree:
    id: int
    parent_id: int | None
    # no parent reference; resolve via id elsewhere

Component graphs in DI containers — services holding references to each other.

Caches with weak references — use weakref.WeakValueDictionary to avoid keeping objects alive.

Interview angle

  • Q: “Can a dict contain itself?” — yes. repr handles it; some serializers don’t.
  • Q: “What does print(d) show for d['self'] = d?” — {'self': {...}}.
  • Follow-up: “What happens with json.dumps?” — ValueError: Circular reference detected.
  • Follow-up: “How does CPython’s GC handle this?” — refcount alone can’t free cycles; cyclic GC sweeps periodically.

See 02_python_core/27_memory_model.md, 14_id_reuse_after_gc.md.