Reference Cycles and the gc Module
debt(d8/e5/b5/t7)
Closest to 'silent in production until users hit it' (d9), slightly better at d8 because tracemalloc/objgraph/pympler can find cycles but only when you go looking after RSS growth is noticed — not part of standard CI.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix says swap strong back-refs to weakref, but in tree/observer structures this typically means updating every access site (deref .ref()), handling None cases, and auditing all back-edges — more than a single-call swap.
Closest to 'persistent productivity tax' (b5). Memory ownership discipline via weakref applies_to web/cli/queue/library contexts and shapes how object graphs are designed, but it's not the system's defining architecture.
Closest to 'serious trap' (t7). The misconception ('gc handles everything automatically') directly contradicts the reality that cycles delay reclamation, __del__ finalizer order is undefined, and C extensions can leak — a competent dev coming from refcount intuition guesses wrong.
Also Known As
TL;DR
Explanation
CPython's primary memory management strategy is reference counting: every object tracks how many references point to it, and when the count drops to zero the object is freed immediately. This is fast and deterministic, but it cannot reclaim objects that reference each other in a cycle - each object keeps the other's refcount above zero even when no external code can reach them. To handle this, CPython runs a supplemental generational garbage collector (the gc module) that periodically walks container objects looking for unreachable cycles.
Common sources of cycles: parent-child structures where the child holds a reference back to the parent, doubly-linked lists, observer/subscriber patterns where objects register callbacks on each other, exception __traceback__ objects capturing local frames that contain the exception, and closures that reference objects which reference the closure. Cycles containing objects with __del__ finalizers were historically uncollectable (before Python 3.4 / PEP 442) - modern Python collects them but finalizer order is undefined.
While the gc module handles cycles automatically, you should still care. Cycle collection is expensive and runs at unpredictable times, causing latency spikes in low-latency services. Memory can bloat between collection runs. In tight loops that create many cycles, the collector overhead becomes measurable. The fix is usually structural: break cycles with weakref.ref or weakref.WeakValueDictionary for parent/observer references, explicitly None-out attributes in cleanup code, or use context managers to ensure timely release. Tools: gc.collect() forces a collection, gc.get_referrers(obj) and gc.get_referents(obj) inspect the graph, and gc.set_debug(gc.DEBUG_LEAK) logs uncollectable objects. For diagnostics, objgraph and tracemalloc pinpoint which objects are retained by cycles.
Common Misconception
Why It Matters
Common Mistakes
- Storing parent references as strong attributes in tree or DOM-like structures instead of weakref.ref, keeping entire subtrees alive forever.
- Catching an exception and storing it on self - the traceback holds frame locals that reference self, creating a cycle that pins large objects.
- Registering callbacks or observers with strong references from both sides, so neither the subject nor the observer is ever collected.
- Assuming __del__ runs promptly for cycled objects - finalizer order is undefined and side effects may fire late or not at all.
- Calling gc.disable() in hot paths without measuring, then forgetting to re-enable it or missing the resulting memory bloat.
Code Examples
class Node:
def __init__(self, value):
self.value = value
self.children = []
self.parent = None # strong reference back to parent
def add_child(self, child):
child.parent = self # cycle: parent -> children -> child -> parent
self.children.append(child)
# Every tree becomes uncollectable via refcount alone:
root = Node('root')
for i in range(100_000):
root.add_child(Node(i))
del root # memory NOT reclaimed until gc.collect() sweeps the cycle
import weakref
class Node:
__slots__ = ('value', 'children', '_parent')
def __init__(self, value):
self.value = value
self.children = []
self._parent = None # will hold a weakref
@property
def parent(self):
return self._parent() if self._parent else None
def add_child(self, child):
child._parent = weakref.ref(self) # no cycle
self.children.append(child)
root = Node('root')
for i in range(100_000):
root.add_child(Node(i))
del root # freed immediately by refcount - no gc pass needed
# Diagnostics when you suspect cycles:
import gc
gc.set_debug(gc.DEBUG_LEAK)
unreachable = gc.collect()
print(f'collected {unreachable} cyclic objects')