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.
reprhandles it; some serializers don’t. - Q: “What does
print(d)show ford['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.