What Keeps an Object Alive
In short: there's no free in Python — an object lives for exactly as long as
something can still reach it, from a local variable, a global, or a chain of
references leading back to either. del name only removes a name; the object
survives if anything else still points at it. CPython frees most garbage the
instant its reference count hits zero, which is why cleanup usually happens
right where you'd expect — but two objects that point at each other keep each
other's count above zero forever, so a separate cycle collector (gc) has to
notice and sweep them. A Python "leak" is never the runtime's fault: it's always
something still reachable that you forgot about — an unbounded cache, a registry,
a closure.

An object is alive while some chain of references leads to it from a root — a running function's local variables, or a loaded module's globals. Here's that idea for a batch of rows and a cycle of nodes at once. Step through it:
An object lives while a chain of references leads to it from a root. Reference counting frees most garbage the moment it's cut loose, but not a cycle.
The roots: the running function's local variables, and the module's globals. 8 nodes, 8 edges, directed. Settled: frame locals, module globals.
The last two steps are the whole story of this lesson. Most garbage disappears the instant it's cut loose. A cycle waits for a second mechanism.
del removes a name, not an object
To watch an object actually die, you need a way to point at it that doesn't count as a reference — otherwise watching it would keep it alive. That's a weak reference:
A weak reference only reports the death. It says nothing about why an object stayed alive — that depends entirely on what else still points at it.
A registry that quietly leaks
A module-level dict that remembers "batches currently in use" sounds harmless. It's Quarry's actual overnight leak:
The registry should let you look batches up without keeping any of them alive forever. Run it, notice the batch survives del, then change one line.
A WeakValueDictionary drops an entry automatically the moment its value dies
everywhere else — exactly the shape a "look up what's still in use" registry
needs. A plain dict, a module-level list, or a closure that captured an object by
accident all leak the same way: not because Python forgot to free something, but
because something still, technically, reaches it.
Two nodes that can't free each other
Reference counting frees an object the instant nothing points at it anymore. Two objects that point at each other break that, because neither count ever reaches zero on its own:
import gc
import weakref
gc.disable() # keep the cycle collector out of it, for now
class Node:
pass
x = Node()
y = Node()
x.other = y
y.other = x
probe = weakref.ref(x)
del x, y
before = probe() is not None
gc.collect()
after = probe() is not None
print(before, after)
gc.enable()Step through the same code, and watch probe. Its display changes to dead
only after the collector runs:
gc only tracks container objects — gc.is_tracked([]) is True,
gc.is_tracked(1) is False — since only containers can form a cycle in the
first place. It runs on its own periodically, and gc.collect() forces it.
One-liner: weak back-references break the cycle on purpose
A tree where every child points back at its parent is a cycle at every level. The standard fix: decide which side owns, and make the other direction weak.
Quick practice
import weakref
class Batch:
pass
a = Batch()
b = a
probe = weakref.ref(a)
del a
print(probe() is None)gc.get_referents(obj) is the collector's own view of what obj points to —
exactly the walk it does when it checks whether a group is reachable from
outside itself.
🎯 Your turn
Write find_cycles(links, roots). links maps each object's name to the names
it refers to, e.g. {"a": ["b"], "b": ["a"], "c": []}. Build a real object for
each name, wire up the references, keep strong references only to the names in
roots, then let go of everything else and report:
"freed_at_once"— names already dead before any collection (reference counting alone got them)"cycle_garbage"— names still alive untilgc.collect()runs"alive"— names still alive afterwards (reachable from a root)
Each list sorted. find_cycles({"a": ["b"], "b": ["a"], "c": []}, []) →
{"freed_at_once": ["c"], "cycle_garbage": ["a", "b"], "alive": []}.
Hint: keep the collector disabled while you build and check, so it can't run early and blur the first two lists — re-enable it before you return. Build inside a helper function so its locals (including any leftover loop variable) go out of scope when it returns.
Press ▶ Run, then ✓ Check. ✅
