skip to content

Cycle Collection and the gc Module

Reference counts can never free objects that point at each other, so CPython runs a generational tracing collector behind them. Expect what gc.collect and gc.disable really do, and when it runs.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why can reference counting alone never free two Python objects that point at each other?

level: juniorimportance: must knowfreq 55%

answer

  1. Counting alone has a blind spot
  2. Each keeps the other alive
  3. Neither count ever reaches zero
  4. A second, tracing collector exists
  5. gc module, container objects only

basics

~20 s

Each object in the cycle holds a reference to the other, so neither count drops to zero even after every outside name is gone. CPython runs a second, tracing collector — the gc module — to find and free such groups.

solid answer

~40 s

CPython frees an object the instant its reference count reaches zero. Two objects that reference each other keep each other's count at one forever, so once the last outside name disappears the pair is unreachable but immortal under counting alone. CPython therefore runs a tracing cycle collector alongside the counts, exposed as the `gc` module. It looks only at container objects — things that can hold references — and finds groups whose references come entirely from inside the group, which means nothing live can reach them. Those are freed together. The practical consequence is that cyclic objects die whenever the collector next runs, not at a predictable moment, so a file handle or socket must be released by a context manager or an explicit close rather than by finalization.

code

python · 13 lines
python
import gc

class Node:
    def __init__(self):
        self.peer = None

a = Node()
b = Node()
a.peer = b
b.peer = a
del a, b          # every outside name is gone; both counts are still 1

print(gc.collect())   # number of unreachable objects the collector found

go deeper

for a junior

Be ready to say that CPython frees an object when its reference count hits zero, and that two objects pointing at each other never reach zero. Naming the gc module as the second, tracing mechanism is enough at this level.

for a middle

Explain which objects the collector tracks — containers, never integers or strings — and how it decides a group is unreachable by discounting the references the group makes to itself. Be able to sketch a cycle in five lines of code.

for a senior

Show that cycle collection is non-deterministic, so resources are released by context managers rather than finalizers, and that the return value of gc.collect() in a steady-state loop is a usable production signal.

for a principal

Own the consequence: cycle-heavy object graphs are a design choice with a memory and latency cost, and code that depends on refcount timing is CPython-only. Say that out loud before a team builds a resource-release strategy on it.

**The problem in one sentence.** CPython frees an object the moment its reference count reaches zero. Two objects that hold references to one another keep each other's count above zero forever, so that rule alone can never reclaim them. **What a cycle looks like.** ```python class Node: def __init__(self): self.peer = None a = Node() b = Node() a.peer = b b.peer = a del a, b ``` After `del a, b` no name in the program can reach either object, yet each still has exactly one reference — the attribute stored on the other. The counts sit at one and never fall. Nothing outside the pair can drop them, and the pair will not drop itself. Cycles are not exotic. Parent/child back-pointers, doubly linked structures, a callback stored on the very object it will call, a closure that captures a name bound to the object holding the closure, and a caught exception whose traceback keeps the frame that holds the exception all form cycles. Any object graph with a back-edge is one. **The second collector.** CPython therefore runs a tracing cycle collector alongside reference counting, exposed as the `gc` module. It supervises only *container* objects — objects that can hold references to other objects. `gc.is_tracked(x)` reports whether a given object is under its supervision: an `int`, a `float` or a `str` cannot reference anything and is never tracked, and CPython also untracks tuples once it has observed that they contain only atomic values. For the tracked set the collector answers one question: which of these objects are referenced *only* from inside the set? It walks the tracked objects and discounts the references they make to one another; anything left with no reference from outside cannot be reached from a live root, so it is garbage even though its real count is positive. Those objects are finalized and freed as a group. **Consequences that matter in an interview.** 1. *Collection is not deterministic.* A non-cyclic object dies the instant its last name goes away. A cyclic one dies whenever the collector next runs — possibly thousands of allocations later, possibly never if automatic collection was turned off. Never rely on finalization to release a file handle, a socket or a lock: use a context manager or an explicit `close()`. 2. *`gc.collect()` returns a number.* It runs a full collection immediately and returns how many unreachable objects it found. A steadily non-zero number in a steady-state loop is a direct signal that the code manufactures cycles. 3. *A `__del__` method inside a cycle is no longer fatal.* Before Python 3.4 a cycle containing an object with `__del__` was declared uncollectable and parked in `gc.garbage` for the life of the process, because the interpreter could not pick a safe finalization order. PEP 442 changed that in 3.4: finalizers now run once, in unspecified order, and the cycle is then freed. On 3.14 `gc.garbage` stays empty in normal operation. 4. *This is a CPython implementation detail.* Counting plus a cycle collector is how CPython works; other Python implementations trace everything and keep no per-object count, so code that happens to work because a count hit zero at the right moment is not portable. **Breaking a cycle deliberately.** You can clear the back-edge yourself when you are finished with a structure, or keep the back-pointer as a non-owning reference so it never contributes to the count at all. Both are ordinary design decisions rather than a fight with the collector. **Two things this is not.** A circular *import* is not a memory problem: the modules involved stay in `sys.modules` for the life of the process, so they remain reachable and the collector never has an opinion about them — a circular import is a startup-ordering bug, not a leak. And an object graph still reachable from a module-level cache or a registry is not garbage at all; no collector will free it. Memory that `gc.collect()` refuses to shrink is a live-reference problem, not a cycle problem, and chasing it with the `gc` module's knobs is wasted effort.

  • Does a circular import between two Python modules create garbage the collector has to clean up?
    No. Both modules stay registered in `sys.modules` for the life of the process, so they are permanently reachable from a live root and are never garbage. A circular import is an execution-order bug — a name is read before the partially initialised module has defined it — and the fix is restructuring the imports, not the `gc` module.
  • What happens on Python 3.14 to a cycle whose objects define `__del__`?
    It is collected. Each finalizer runs exactly once, in an order the interpreter does not promise, and the cycle is then freed. That has been true since PEP 442 in Python 3.4; before it, such cycles were declared uncollectable and appended to `gc.garbage` forever, which is where the old advice to avoid `__del__` entirely comes from.
  • If cycles are collected anyway, why should anyone care that they exist?
    Because the timing changes. Non-cyclic objects are freed at the instant of the last decref; cyclic ones wait for a collection pass, so memory sits high in between and any resource release attached to finalization is delayed by an unbounded amount. In allocation-heavy code, cycles also make the collector run more often, which costs CPU.

Two climbers each clipped only to the other: counting the ropes attached to each one never reaches zero, so someone has to look at the whole rope system from outside and notice the pair is anchored to nothing.

saying these in an interview costs you the question

  • Says Python has no garbage collector, only reference counting
  • Claims `del` frees the object rather than removing a name
  • Thinks a cycle leaks permanently on modern Python
  • Believes `__del__` in a cycle still makes it uncollectable
  • Relies on finalization to close files or sockets
  • Calls a circular import the same thing as a reference cycle

context

open as a page

When does CPython's cycle collector actually run, and what do the gc thresholds control?

level: middleimportance: should knowfreq 45%

basics

~20 s

It is driven by allocation pressure, not by memory use or a timer. CPython counts tracked container allocations minus deallocations; when that surplus passes the first threshold from gc.get_threshold(), a young-generation pass runs and survivors are promoted.

open as a page

How do you confirm reference cycles are behind a Python transcript archiver's memory growth across 6,800-row batches?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Force a gc.collect() between batches and watch its return value and the object counts. If a forced pass reclaims a lot, the growth is cyclic garbage awaiting collection; if it reclaims nothing, the objects are still reachable and the collector is irrelevant.

open as a page

When is calling gc.disable() in a production Python service defensible, and what breaks?

level: seniorimportance: should knowfreq 30%

basics

~20 s

It stops automatic cycle collection only — reference counting keeps freeing everything acyclic — so it is defensible in short-lived or fork-heavy processes. What breaks is cyclic garbage: it accumulates without bound unless the code calls gc.collect() at safe points.

open as a page