Reference counting frees an object the instant its count hits zero. It therefore cannot free this:
class Node:
def __init__(self) -> None:
self.parent: Node | None = None
self.children: list[Node] = []
root = Node()
child = Node()
root.children.append(child)
child.parent = root
del root, child # both counts are still 1. Nothing is freed.
Neither object’s count reaches zero, because each is referenced by the other. From the outside they are unreachable; from the refcount’s point of view they are perfectly alive. That is what the cycle collector exists for, and the CPython docs are precise about its scope: “The garbage collector only focuses on cleaning container objects” — objects that can hold references to other objects, which is what tp_traverse means. A float cannot participate in a cycle, so it is never tracked.
How the collector decides
From CPython’s own design document (InternalDocs/garbage_collector.md), the algorithm on a generation is:
-
Every tracked object carries a
PyGC_Head— a doubly-linked list node plus space for a scratch count. -
Copy each object’s real refcount into that scratch field (
gc_ref). -
Walk every tracked object’s
tp_traverse, and for each reference it holds to another tracked object, decrement that object’sgc_ref. -
What is left in
gc_refis the number of references from outside the set under examination. Anything withgc_ref > 0is externally reachable; so is anything reachable from it. - Everything else is unreachable garbage and can be collected.
That is the whole idea: subtract the internal references, and see who still has an owner.
Three generations with default thresholds (2000, 10, 10): a collection of generation 0 is triggered when allocations minus deallocations exceed 2000, generation 1 after 10 generation-0 passes, generation 2 after 10 generation-1 passes. The hypothesis is the usual one — most objects die young — so the cheap pass runs often and the expensive full pass rarely.
💡gc.get_threshold() returns (2000, 10, 10) and you have a service whose gen-2 collections cause 400 ms pauses. Why does raising threshold0 help, why does raising it too far hurt, and what would you measure to decide?
click to reveal
Raising threshold0 makes gen-0 collections less frequent, so more short-lived garbage accumulates before each pass. That reduces the number of pauses and, indirectly, gen-2 pauses too, because gen-1 and gen-2 are triggered by counts of the generation below.
The cost is peak memory and pause duration. Objects that would have been reclaimed at 2,000 allocations now survive to 20,000, so your resident set carries more dead objects at any moment, and each pass has more to walk. You have not removed work; you have batched it. On a latency-sensitive service that trade is often right; on one already near its memory limit it is exactly wrong.
What to measure: gc.callbacks gives you a hook that fires on every collection with the generation and the object counts, so you can histogram real pause durations per generation rather than guessing. Pair it with RSS. Then change one threshold and compare. Tuning the collector from first principles without that data is how people end up with gc.disable() in production.
Why cycles are a latency problem, not a leak
Cycles are collected — this is not a leak in the C sense. The problem is that destruction has become asynchronous and unbounded. Consider what that means for a cache of parsed documents where each parsed node points at its parent:
- The memory is not freed when you drop the document. It is freed on some later gen-2 pass.
- Until then it counts against your container limit.
- Under steady load, allocation outruns collection, RSS climbs, then a gen-2 pass reclaims a large batch — the sawtooth.
- If a burst arrives while the sawtooth is near its peak, the OOM killer arrives first.
So “we have no leak, the GC handles it” and “we OOM in production” are entirely compatible statements.
The shapes that create cycles without you noticing
- Parent/child trees. Any back-pointer.
- Doubly-linked lists. By construction.
- Observer registration. The subject holds the observer; the observer holds the subject.
-
Closures capturing
self.self._callback = lambda evt: self.handle(evt)is a cycle through the cell object. -
functools.partial(self.method)stored onself. Same thing, with a nicer name. -
An exception stored on the object that raised it. The traceback holds the frame, which holds
self.
The fix, where prompt destruction matters, is usually a weakref for the back-edge (article 11.6): a child that weakly refers to its parent breaks the cycle without changing the ownership story.
PEP 442 and objects with __del__
Before Python 3.4, an unreachable cycle containing an object with a __del__ method was not collected at all — it was moved to gc.garbage for a human to deal with, because the interpreter could not decide a safe finalisation order. That is the origin of “never write __del__“ as folklore.
PEP 442 fixed it. Finalisers are now called on cyclic garbage, once, before the objects are freed, and the cycle is then reclaimed. gc.garbage stays empty in normal operation.
Which changes the advice but does not reverse it. __del__ is now collectable; it is still not prompt, still not guaranteed to run at interpreter shutdown, still runs at an arbitrary point in an arbitrary thread, and still cannot report an error usefully — exceptions in __del__ are printed and swallowed. Use it to release memory you own; use with for everything else.
💡gc.collect() returns the number of unreachable objects found. A colleague adds gc.collect() at the end of each request handler "to keep memory flat". Argue both sides.
click to reveal
For: it makes destruction synchronous and predictable at a known point, which flattens the sawtooth and can genuinely stop an OOM. If a request builds a large cyclic structure — a parsed AST, a document tree, an ORM identity map — collecting at the boundary is a defensible, targeted choice, and it is measurable.
Against: a full gc.collect() walks every tracked object in the process, not just this request’s. Cost scales with total heap size, so as the process’s long-lived structures grow, the per-request tax grows with them — you have added an O(heap) operation to an O(request) code path. Under concurrency it is worse: on the GIL build the pause stops every thread.
The compromise usually worth trying first is gc.collect(0), which only sweeps the youngest generation and is cheap, or raising the thresholds so collections are less frequent but still automatic. And if the long-lived structures are the reason a full pass is expensive, gc.freeze() (article 11.5) moves them out of the collector’s reach entirely.
Walking the graph yourself
gc.get_referents(obj) asks an object’s tp_traverse what it points at — the same primitive the collector uses. gc.get_referrers(obj) is the reverse and is much more expensive, because it must scan every tracked object in the process.
get_referents is the right tool for answering “what does this thing actually hold onto?”, and it comes with two requirements that the accompanying problem exists to teach. Objects are frequently unhashable, so your visited set must be keyed on id(), not on the objects themselves. And real object graphs are deep, so a recursive traversal meets the recursion limit long before it meets a real heap. Both constraints push you to the same shape: an explicit stack plus a set[int].