Skip to content
Path to Engineer
All lessons
PythonPython internals14 min read

How Python's garbage collector works

Reference counting plus the cyclic collector: generations, thresholds, and when a collection actually runs in a running program.

Day 27 of the 488-day pathway. Published in full — nothing held back.

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 e name at the end of an except block — and why storing the exception on self or in a global brings the cycle back and pins every local in every frame.


Mini task

  1. Build a two-object cycle with __del__ on both. del your names — nothing prints. Call gc.collect()now it does.
  2. gc.get_count() in a loop while allocating. Watch gen 0 climb toward 700 and reset.
  3. gc.is_tracked() on an int, a str, a list, (1, 2), and (1, []). Explain each answer.
  4. Time a loop that allocates heavily, with the GC on and off. Record both numbers.
  5. Build a 500,000-object graph, keep it alive, and measure the pause of gc.collect(). That is the generation-2 cost.
  6. Store an exception on a global and use gc.get_referrers to find its frames.
  7. Build a parent/child tree with strong parent pointers, confirm the cycle, then convert to weakref and confirm refcounting alone frees it.
  8. Read gc.garbage after gc.set_debug(gc.DEBUG_SAVEALL); gc.collect().

Exit questions

Answer aloud, no notes.

  1. What frees most objects in CPython? What does the GC actually do?
  2. Show a cycle refcounting cannot free.
  3. Name four cycles that occur in ordinary code.
  4. Which objects does the collector track, and why not all of them?
  5. Describe the mark-and-sweep algorithm it uses.
  6. What are the three generations and their thresholds?
  7. When exactly does gen 0 run? What follows for an idle program?
  8. Describe the large-stable-graph pathology.
  9. What does gc.disable() disable — and what does it risk?
  10. What is gc.freeze() for, and when do you call it?
  11. Why does Python 3 delete the except ... as e name?
  12. 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