Hermes Wiki
Developer/Languages/Python/LanguageInternals/Fundamentals/reference-counting-and-generational-garbage-collection-in-cpython

Reference Counting and Generational Garbage Collection in CPython

Concept

Every Python object allocated by CPython carries a hidden ob_refcnt field in its C struct. Assigning an object to a new name, appending it to a list, or passing it as a function argument increments that count; letting a name go out of scope, del-ing it, or removing it from a container decrements it. The instant a refcount hits zero, CPython frees the object immediately and deterministically — no separate sweep phase, no unpredictable pause. This is why closing a file object or releasing a lock via __del__ "just works" the moment the last reference disappears, in a way it does not in a purely tracing-GC language like Java or Go.

Reference counting alone cannot free a reference cycle — two or more objects that reference each other (a doubly-linked list node, a parent-child object graph, or simply a closure capturing itself) can have a nonzero refcount forever even though nothing outside the cycle can reach them. CPython's second mechanism, the generational cyclic garbage collector (the gc module), exists purely to catch this case. It tracks container objects (anything that can hold references to other objects — lists, dicts, class instances, but not plain ints or strings) in three generations, 0/1/2, based on the classic generational hypothesis that most objects die young. New tracked objects start in generation 0; an object that survives a generation-0 collection is promoted to generation 1, and one that survives a generation-1 collection moves to generation 2. Collection frequency drops per generation — the GC's threshold counters (default roughly 700/10/10 object-allocation deltas) mean generation 0 is scanned often and cheaply, while the oldest, most stable objects in generation 2 are scanned rarely, since they are statistically the least likely to have just become garbage.

Tradeoffs

Mechanism Benefit Cost
Reference counting (primary) Deterministic, immediate collection — no GC pause for the common case; predictable __del__ timing Every assignment/dereference pays an atomic-adjacent increment/decrement; cannot collect cycles at all on its own
Generational cyclic GC (backstop) Catches reference cycles reference counting structurally cannot; tuned to skip work on long-lived, stable objects Adds a real (if brief) stop-the-world scan when it runs; scanning the wrong generation threshold too aggressively wastes CPU re-scanning objects unlikely to be garbage
Manual cycle-breaking (weakref, explicit .close()/context managers) Avoids relying on the cyclic GC at all for hot paths; predictable, refcounting-only cleanup Requires the developer to actually find and break the cycle — easy to miss in nested object graphs, parent-back-references, or closures

When to use / when not to

  • Rely on plain reference counting for the vast majority of code — it requires no thought, and disabling the cyclic GC (gc.disable()) is occasionally a legitimate optimization for short-lived, cycle-free scripts (e.g. a batch job that allocates and exits) where the cyclic scan's overhead outweighs its benefit.
  • Reach for weakref deliberately when building any structure with natural back-references — a parent object holding a list of children who each hold a reference back to their parent, an observer pattern, or a cache keyed by object identity — since that shape is exactly what creates uncollectable-by-refcount cycles.
  • Don't assume __del__ firing means "the interpreter needed a GC pass" — for the non-cyclic majority of objects, __del__ fires the instant the refcount hits zero, with no cyclic collector involvement at all.
  • Watch gc.disable() carefully in long-running services (web servers, workers): disabling the cyclic collector entirely trades away cycle collection for the lifetime of the process, and any accidental cycle becomes a genuine, invisible memory leak instead of a cyclic-GC cleanup that would otherwise have caught it eventually.

Common pitfall

Assuming CPython is a purely reference-counted runtime and that avoiding "big objects" is enough to avoid memory growth — the actual leak pattern in production Python services is almost always an accidental reference cycle that keeps getting promoted into generation 2 (because it "survives" every young-generation sweep, since nothing outside the cycle can free it) and then sits there, correctly un-collected by refcounting and only reclaimable the next time the increasingly-rare generation-2 sweep actually runs. A classic instance: a custom exception class that stores a reference to the traceback (which in turn references the stack frames, which reference local variables, which may reference the exception again) — caught-and-logged exceptions in a hot loop can quietly accumulate cyclic garbage this way long before anyone thinks to suspect the GC.

Engineering Lens

The three-generation split matters most under sustained allocation pressure — a request-handling loop that creates and discards thousands of short-lived objects per request benefits enormously from generation 0 being cheap to scan and quick to discard, while a long-lived cache or connection pool that survives into generation 2 correctly stops paying that scanning cost on every cycle. When production memory growth looks gradual and doesn't correlate with request volume the way a simple accumulation bug would, that shape — slow, steady, decoupled from load — is itself a signal worth checking against gc.get_stats() or gc.collect() with gc.set_debug(gc.DEBUG_STATS) before reaching for a heap profiler; it's often cheaper to first confirm or rule out "reference cycle building up in generation 2" than to jump straight to tracemalloc or an external memory profiler.

Sources

Hermes Wiki