Backend / Python core / Tricky questions / 37_self_referential_dict.md

Self-referential dicts (and lists)

Updated 2 min read source
On this page7
  1. The gotcha
  2. Minimal repro
  3. What about traversal?
  4. Standard library handling
  5. Memory and garbage collection
  6. When self-references happen accidentally
  7. Interview angle

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

python
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?

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

Manual traversal must track visited objects:

python
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
python
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.

python
import gc
d = {}
d['self'] = d
# refcount goes from 2 to 1 (self-ref still holds)
del d
                        # 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 Python memory model — allocation, refcounting, GC, id() reuse after garbage collection.

When self-references happen accidentally

Dataclass with parent pointer:

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

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

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

python
@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 4

  • 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 Python memory model — allocation, refcounting, GC, id() reuse after garbage collection.