How Python's garbage collector works
Reference counting plus the cyclic collector: generations, thresholds, and when a collection actually runs in a running program.
The cyclic garbage collector — generations, thresholds, and when it actually runs
The sentence to own: CPython's garbage collector does not collect garbage. Reference counting does that. The GC exists for exactly one job — freeing reference cycles — and it runs on allocation counts, not on a timer.
That distinction matters practically:
gc.disable()does not turn off memory management, and people who think it does are surprised in both directions.
Part 1 — The one problem refcounting cannot solve
a = {}
b = {}
a["b"] = b # b's refcount: 2
b["a"] = a # a's refcount: 2
del a, b # both drop to 1 — they still refer to EACH OTHER
# ⇒ UNREACHABLE from anywhere, but never freed by refcounting alone.
THE CYCLE IS NOT A CORNER CASE. It is everywhere in ordinary code:
· a doubly linked list, a tree with parent pointers
· an exception ⇒ traceback ⇒ frame ⇒ local variable ⇒ the exception ( classic)
· a class instance whose method is stored as its own attribute (self ⇒ bound method ⇒ self)
· a closure that refers to the object holding it
· any graph or ORM object with bidirectional relationships
A self-reference counts: lst = []; lst.append(lst) is a one-element cycle.
Part 2 — How the collector finds them
IT TRACKS ONLY CONTAINER OBJECTS — things that can REFER to other objects:
⇒ list, dict, set, tuple, class instances, functions...
⇒ NOT int, float, str, bytes — they cannot participate in a cycle, so they are ignored.
( gc.is_tracked(x) tells you. A tuple of only immutables gets UNTRACKED as an
optimisation, which is why some tuples answer False.)
THE ALGORITHM — mark and sweep, restricted to tracked objects:
1. take a snapshot of each tracked object's refcount
2. for each tracked object, DECREMENT the counts of everything it refers to
⇒ this cancels out all references INTERNAL to the tracked set
3. anything left with count > 0 is referenced from OUTSIDE ⇒ reachable, keep it
4. mark everything reachable FROM those, transitively
5. whatever is still at 0 is a pure cycle ⇒ free it
Generations — the reason it is cheap
THE GENERATIONAL HYPOTHESIS: most objects die young.
⇒ so check the young ones often and the old ones rarely.
GEN 0 — new objects. threshold 700 ⇒ collected constantly, and it is tiny
GEN 1 — survived one pass. threshold 10 ⇒ collected after 10 gen-0 passes
GEN 2 — survived two. threshold 10 ⇒ the FULL, EXPENSIVE collection
import gc
gc.get_threshold() # (700, 10, 10)
gc.get_count() # current counts per generation
WHEN GEN 0 RUNS — and this is the part almost nobody knows:
NOT on a timer. NOT on memory pressure.
⇒ when (allocations − deallocations) since the last pass exceeds 700.
⇒ SO: a program that allocates nothing NEVER collects, no matter how long it runs.
⇒ and one that churns objects collects constantly.
⚠ The pathological case worth knowing: a large, long-lived object graph — a big cache, a loaded model, a parsed document. It gets promoted to generation 2, and every full collection then walks all of it, repeatedly, finding nothing. In a service with a 5 GB in-memory structure this is a measurable, recurring latency spike caused entirely by scanning objects that are obviously alive. Instagram's well-known result — disabling the GC and gaining ~10% CPU — is exactly this.
Part 3 — The gc module, and when to touch it
import gc
gc.collect() # force a full collection; returns the number of objects freed
gc.disable() / enable() # disables the CYCLE collector ONLY — refcounting still runs
gc.freeze() # move everything to a permanent generation — call BEFORE fork
gc.get_objects() # ⚠️ every tracked object. Huge. Debugging only.
gc.get_referrers(obj) # who points at this — the leak-hunting tool (Day 026)
gc.set_debug(gc.DEBUG_SAVEALL) # keep collected objects in gc.garbage to inspect them
gc.is_tracked(x)
| Action | When it is genuinely right |
|---|---|
gc.freeze() before forking |
always, for pre-forked workers — keeps the shared pages clean (Day 026) |
gc.disable() |
a large stable object graph + you have measured it; ⚠ and you must be sure you make no cycles |
gc.collect() explicitly |
after a big teardown, or between batch jobs — never in a hot path |
| Tuning thresholds | rarely; measure first |
⚠ gc.disable() is not free of risk. Cycles you create afterwards are never collected — that
is a genuine, unbounded leak. It is a measured optimisation for a specific workload, not a default,
and it belongs behind a comment explaining what was measured.
__del__ in a cycle
Before Python 3.4: an object with __del__ inside a cycle was UNCOLLECTABLE —
the interpreter could not decide the finalisation order, so it gave up and
parked it in gc.garbage forever. A real, permanent leak.
⇒ PEP 442 fixed this: finalisers now run once, then the cycle is collected.
⇒ but the collection is still DELAYED until the collector runs —
which is Day 026's point again: __del__ is not cleanup.
Part 4 — Avoiding cycles in the first place
# 1. Weak back-references (Day 026) — the parent pointer is the classic cycle
import weakref
class Node:
def __init__(self, parent=None):
self.children = []
self._parent = weakref.ref(parent) if parent else None # no cycle
@property
def parent(self): return self._parent() if self._parent else None
# 2. Do not store exceptions
try: ...
except Exception as e:
log.exception("failed") # logs and lets it go
# ⚠️ self.last_error = e # pins the traceback ⇒ every frame ⇒ every local
# NOTE: Python 3 already deletes `e` at the end of the except block, FOR THIS REASON.
# 3. Break cycles explicitly on teardown, when you own the structure
def close(self):
for c in self.children: c.parent = None
self.children.clear()
The exception one is the most common in real services, and it is worth knowing that Python 3
deletes the except ... as e name at the end of the block specifically to break the
exception → traceback → frame → exception cycle. Storing e on self or in a global re-creates
it, and pins every local variable in every frame of that stack.
AND THE FRAMING THAT MATTERS MOST (Day 026, restated because it is the exam answer):
THE GC ONLY FREES UNREACHABLE OBJECTS.
⇒ An unbounded cache, a global list, a growing dict — all REACHABLE.
Not garbage. Not collectable. Still an outage.
⇒ Most "Python memory leaks" are not leaks. They are things you remembered on purpose.
Common mistakes
| Mistake | Correction |
|---|---|
| "The GC frees my objects" | Refcounting does. The GC only handles cycles. |
gc.disable() disables memory management |
It disables cycle collection only. |
gc.disable() as a default optimisation |
Any cycle you make afterwards leaks permanently. |
| Expecting the GC on a timer | It is allocation-count driven. No allocations, no collection. |
Not calling gc.freeze() before fork |
Collector writes to headers ⇒ COW copies (Day 026). |
| Assuming cycles are rare | Trees with parents, tracebacks, ORM graphs. |
| Storing exception objects | Pins the traceback, frames and every local. |
| Thinking a GC prevents leaks | Reachable is not garbage. |
Using __del__ for cleanup |
Delayed by cycles, unreliable everywhere. with. |
| Tuning thresholds without measuring | Measure, then tune, then comment. |
Interview questions
Q: Does Python have a garbage collector?
It has both, and they do different jobs. Reference counting frees almost everything, immediately. The generational collector exists for exactly one case refcounting can't handle — reference cycles, where a group of objects refer to each other and stay above zero while being unreachable. So when people say "Python's GC", they usually mean the cycle collector, which is responsible for a small fraction of the memory actually freed.
Q: How does the cycle collector work?
Mark and sweep over container objects only — anything that can reference something else. It takes each tracked object's refcount, then subtracts the references that come from inside the tracked set; anything left with a positive count is reachable from outside, and everything reachable from those is kept. What remains at zero is a pure cycle and gets freed. It's generational: new objects are in gen 0 and checked often, survivors are promoted and checked progressively less, so full collections are rare.
Q: When does it run?
On allocation counts, not on a timer or on memory pressure. Gen 0 is collected when allocations minus deallocations since the last pass exceeds 700; gen 1 after ten gen-0 passes; gen 2 after ten of those. A program that stops allocating never collects, regardless of how long it runs.
Q: When would you disable the GC?
When there's a large, long-lived object graph — a big cache or a loaded model — that gets promoted to generation 2, so every full collection walks all of it and finds nothing. That's a recurring latency cost for no benefit, and it's the situation behind Instagram's well-known result of disabling the collector and gaining around 10% CPU. But it's a measured optimisation, not a default: after disabling it, any cycle the code creates is a permanent leak, so I'd want to be confident about the object graph and I'd leave a comment saying what was measured.
Q: What's gc.freeze() for?
Moving all current objects into a permanent generation that's never collected — called after loading the application and before forking workers. Without it, the collector walks the shared objects in each child and writes to their headers, which triggers copy-on-write and privately copies the memory you forked precisely to share. It's a one-line fix for the worker-memory blow-up.
Q: Are cycles common?
More than people expect. Any tree with parent pointers, any bidirectional ORM relationship, and most notably exceptions: the exception references a traceback, which references frames, which reference locals, which can include the exception. That's why Python 3 deletes the
as ename at the end of anexceptblock — and why storing the exception onselfor in a global brings the cycle back and pins every local in every frame.
Mini task
- Build a two-object cycle with
__del__on both.delyour names — nothing prints. Callgc.collect()— now it does. gc.get_count()in a loop while allocating. Watch gen 0 climb toward 700 and reset.gc.is_tracked()on an int, a str, a list,(1, 2), and(1, []). Explain each answer.- Time a loop that allocates heavily, with the GC on and off. Record both numbers.
- Build a 500,000-object graph, keep it alive, and measure the pause of
gc.collect(). That is the generation-2 cost. - Store an exception on a global and use
gc.get_referrersto find its frames. - Build a parent/child tree with strong parent pointers, confirm the cycle, then convert to
weakrefand confirm refcounting alone frees it. - Read
gc.garbageaftergc.set_debug(gc.DEBUG_SAVEALL); gc.collect().
Exit questions
Answer aloud, no notes.
- What frees most objects in CPython? What does the GC actually do?
- Show a cycle refcounting cannot free.
- Name four cycles that occur in ordinary code.
- Which objects does the collector track, and why not all of them?
- Describe the mark-and-sweep algorithm it uses.
- What are the three generations and their thresholds?
- When exactly does gen 0 run? What follows for an idle program?
- Describe the large-stable-graph pathology.
- What does
gc.disable()disable — and what does it risk? - What is
gc.freeze()for, and when do you call it? - Why does Python 3 delete the
except ... as ename? - Why is an unbounded cache not something the GC can help with?
Articulation drill
Record two minutes: "Explain Python's garbage collection."
Correct the framing immediately, because it is the difference between a memorised answer and an understood one: reference counting frees almost everything, immediately; the "garbage collector" is a cycle collector that exists for the one case refcounting cannot handle. Then generations and the allocation-count trigger — not a timer.
Then the engineering half: "the case where this actually shows up is a large, long-lived object graph — it gets promoted to the oldest generation, and every full collection walks all of it and finds nothing. That is a real recurring latency cost, and it's why some services disable the collector entirely and gain CPU. The catch is that after disabling it, any cycle you create leaks permanently — so it is a measurement, not a default."
And close on the honest point: "most memory problems I've seen aren't cycles at all. They're reachable objects — a cache with no eviction, a global that only grows. No collector can help with something you are deliberately holding onto."
Previous: Day 026 · Tomorrow: Day 028 — memory: object overhead,
__slots__, interning, and the small-integer cache