skip to content

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

level: middleimportance: should knowfreq 45%

answer

  1. Not a timer, not a memory watermark
  2. Allocations minus deallocations, counted
  3. Survivors move somewhere colder
  4. gc.get_threshold and gc.get_count
  5. A zero first threshold turns it off

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.

solid answer

~40 s

Automatic cycle collection is triggered by allocation, not by how much memory the process holds. CPython keeps a running surplus of tracked container allocations over deallocations; when it crosses the first value of `gc.get_threshold()` — 2000 on 3.14 — the youngest generation is collected. Objects that survive are promoted to an older generation, which is examined far less often, so long-lived data is not rescanned on every pass. `gc.get_count()` shows where the counters currently stand, `gc.set_threshold()` moves the trigger points, and `gc.set_threshold(0)` disables automatic collection altogether. `gc.collect()` forces a full pass on demand and `gc.collect(0)` a young-only one. Two consequences matter in production: a program that allocates few containers may go a long time without a pass, and an allocation-heavy loop can spend real CPU in the collector.

code

python · 8 lines
python
import gc

print("thresholds:", gc.get_threshold())
print("counts:", gc.get_count())
print("enabled:", gc.isenabled())

for generation, stats in enumerate(gc.get_stats()):
    print(generation, stats["collections"], stats["collected"])

go deeper

for a junior

Know that automatic collection exists and runs on its own schedule, and that gc.collect() forces it. Being able to say that ordinary objects are freed by reference counting without any of this is already a good answer here.

for a middle

Explain the allocation-surplus trigger and promotion between generations, and name gc.get_threshold(), gc.get_count() and gc.set_threshold(). An interviewer expects you to reject 'it runs every N seconds' immediately.

for a senior

Demonstrate measurement before tuning: gc.get_stats() or a gc.callbacks hook to quantify pauses, then a threshold change justified by numbers, with the memory-versus-CPU tradeoff stated in both directions.

for a principal

Own the policy question of whether collector tuning belongs in application code at all — it is process-wide, invisible to the next reader, and easily invalidated by a runtime upgrade. Prefer reducing cycle creation, and record any tuning as a measured decision.

**The trigger is allocation, not memory.** Nothing in CPython watches the process's memory footprint or runs the cycle collector on a timer. The interpreter keeps a counter for the youngest generation: every time a tracked container object is allocated it goes up, every time one is deallocated it goes down. When that surplus exceeds the first collection threshold, a collection of the young generation runs. That is the whole trigger. A process can hold gigabytes and never collect if it stops allocating containers, and a process that allocates and frees millions of small lists can collect constantly while its memory stays flat. **Generations.** Objects the collector has just started tracking sit in the youngest generation, which is scanned often. Anything that survives a pass is promoted to an older generation and examined much less frequently. The rationale is the standard weak-generational observation: most objects die young, so rescanning long-lived data on every pass is wasted work. On Python 3.14 the trigger values are visible directly: ```python import gc print(gc.get_threshold()) # (2000, 10, 0) on CPython 3.14 print(gc.get_count()) # live counters for the three generations ``` The first value is the allocation surplus that fires a young collection. The second counts young collections before the middle generation is included. On 3.14 the third value is `0` because the oldest objects are no longer collected in one stop-the-world sweep driven by a counter — CPython reworked the handling of long-lived objects to spread that work out and shorten pause times on large heaps. Read the tuple from the interpreter you actually run rather than memorising numbers, because these internals move between releases. **The controls.** - `gc.collect()` runs a full collection now and returns the number of unreachable objects found. `gc.collect(0)` restricts it to the youngest generation. - `gc.set_threshold(t0, t1, t2)` moves the trigger points. Raising `t0` means fewer, larger passes: less CPU spent collecting, more cyclic garbage resident between passes. - `gc.set_threshold(0)` disables automatic collection — the documented meaning of a zero first threshold — while leaving explicit `gc.collect()` calls working. - `gc.disable()` and `gc.enable()` toggle automatic collection outright, and `gc.isenabled()` reports the state. - `gc.get_stats()` returns one dictionary per generation with `collections`, `collected` and `uncollectable` counts. Sampling it over time is the honest way to see what the collector is costing you. - `gc.callbacks` is a list of callables invoked before and after each collection, which is how you export collection counts and durations to metrics without patching anything. **Reference counting is unaffected by all of this.** Every knob here concerns the tracing pass that finds cycles. Non-cyclic objects are still freed the moment their last reference goes away, whatever the thresholds say — which is why turning the collector off does not stop most memory from being reclaimed. **Where this shows up in real systems.** Two shapes recur. The first is an allocation-heavy loop that builds and discards many short-lived containers: the young counter crosses the threshold constantly, and profiling shows a visible slice of wall time inside collection even though almost nothing is ever cyclic. Raising the first threshold is the targeted fix, and `gc.callbacks` or `gc.get_stats()` is how you confirm the improvement rather than guessing. The second shape is the opposite: a service that allocates almost nothing after startup but holds a large object graph. It collects rarely, which is fine, but every pass it does run walks the whole promoted graph. Freezing the startup graph out of the collector's view is the standard answer there. **What tuning cannot do.** Thresholds change *when* cycles are collected; they never change *whether* something is garbage. If memory grows because a module-level cache keeps appending, the objects are reachable and no threshold, generation or forced collection will touch them. Reaching for `gc.set_threshold()` before establishing that the growth is actually cyclic is the most common wasted afternoon in this area.

  • What is the practical difference between `gc.set_threshold(0)` and `gc.disable()`?
    Both stop automatic cycle collection and leave explicit `gc.collect()` working; a zero first threshold is documented as disabling collection. The difference is bookkeeping: `gc.isenabled()` still reports true after `set_threshold(0)`, so a library that checks the flag before doing something clever will be misled, whereas `gc.disable()` sets the flag other code actually reads.
  • How would you tell whether the collector is costing you measurable CPU?
    Sample `gc.get_stats()` over a window and look at the collections count per generation, or append a callback to `gc.callbacks`, which is invoked at the start and end of each pass with the generation and the number collected — timing between the two gives real pause durations. Guessing from a profile that shows time in the interpreter is not enough.
  • Does raising the first threshold risk anything beyond higher memory use?
    Yes: latency shape. Fewer passes means each one has more tracked objects to walk, so you trade frequent short pauses for rarer longer ones. On a request-serving process that can move the tail latency you care about, so the change should be measured against percentiles, not just average throughput.

saying these in an interview costs you the question

  • Says collection runs on a timer or a background thread
  • Thinks the trigger is total memory used
  • Believes thresholds affect reference counting too
  • Quotes fixed default thresholds without checking the build
  • Tunes thresholds before proving the garbage is cyclic
  • Confuses generations with anything the allocator does

context