BLOG · NINETYFIVE ENGINEERING

How Python decides what to free,
and when to go looking.

Most Python developers never think about garbage collection, which is a compliment to its design. Anyone running Python in a latency-sensitive service eventually does, because the collector is the one part of the runtime that can stop everything for a user-visible interval, and because the rules that decide when it runs are not the ones people usually assume.

This is a walkthrough of those rules: the two mechanisms Python uses to reclaim memory, how the cyclic collector decides what is unreachable, what actually triggers a collection, and how the free-threaded build in Python 3.13 counts allocations now that threads run in parallel. That last piece is the least documented, and it is where the arithmetic gets interesting.

The two-part system

Python manages memory with two mechanisms that solve different halves of the problem.

Reference counting frees an object the instant the last reference to it goes away. It is immediate, it is deterministic, and it handles the overwhelming majority of objects in any real program. It also has one blind spot.

Cyclic garbage collection exists to cover that blind spot: groups of objects that reference each other, where every count stays above zero even though nothing outside the group can reach them. This is the part that runs periodically, and the part with pauses, so it is the part worth understanding.

Reference counting frees immediately; the cyclic collector sweeps periodically Top path: an object whose reference count reaches zero is deallocated immediately by Py_DECREF. Bottom path: two lists that reference each other each keep a count of one, so reference counting never frees them, and only a later run of the cyclic collector can reclaim them. PATH 1 · REFERENCE COUNTING, IMMEDIATE OBJECT refcnt 1 Py_DECREF OBJECT refcnt 0 _Py_Dealloc() PATH 2 · A CYCLE, INVISIBLE TO REFERENCE COUNTING LIST A refcnt 1 LIST B refcnt 1 nothing outside can reach either one, and neither count ever reaches zero ONLY THE CYCLIC COLLECTOR CAN FREE THIS
Two mechanisms, two costs. The top path is free and immediate. The bottom path is why Python has pauses at all.

Reference counting

Every Python object carries a count of the references to it in ob_refcnt. Assigning it to a name, passing it to a function, or storing it in a container increments the count; letting any of those go decrements it. When the count hits zero the object is freed on the spot.

SIMPLIFIED FROM Include/refcount.h

/* Every reference gained */
static inline void Py_INCREF(PyObject *op)
{
    op->ob_refcnt++;
}

/* Every reference lost */
static inline void Py_DECREF(PyObject *op)
{
    if (--op->ob_refcnt == 0) {
        _Py_Dealloc(op);   /* free it now, no collector involved */
    }
}

This is why Python's memory use is usually so predictable, and it is worth appreciating before we get to the collector: the collector is the exception path, not the main one.

The problem with cycles

Reference counting fails on exactly one shape, and it fails silently.

A CYCLE THAT REFERENCE COUNTING CANNOT FREE

a = []
b = []
a.append(b)      # b's refcount is now 2: name b, plus a[0]
b.append(a)      # a's refcount is now 2: name a, plus b[0]

del a, b         # both names gone, both objects still at refcount 1
                 # neither is reachable, neither is freed

Two objects, each holding the only remaining reference to the other. No name refers to either, nothing in the program can reach them, and both counts sit stubbornly at one. Reference counting has no way to notice this, because from each object's local point of view it is still in use.

In an inference server this shape is not exotic. Request context objects that point at their own response builders, exception tracebacks that reference the frames that raised them, caches that hold back-references, and any nontrivial async machinery all produce cycles routinely.

Cyclic garbage collection

The cyclic collector examines container objects, the only ones that can participate in a cycle, and asks a question that sounds harder than it is: which of these are referenced only by each other?

The algorithm, in gc_collect_region(), is elegant. Copy every tracked object's reference count into a scratch field. Then walk every reference held by objects inside the region and decrement the scratch count of whatever it points at. When the pass finishes, each scratch count holds the number of references from outside the region. Anything sitting at zero is reachable only from within the group, which means it is not reachable at all.

How the collector separates internal references from external ones Three objects are examined. Before the pass, each holds its real reference count in a scratch field. The collector subtracts one for every reference held from inside the region. After the pass, the object still referenced by a live variable outside the region has a scratch count of one and survives, while the two objects in a cycle reach zero and are collected. STEP 1 · COPY REFCOUNTS INTO A SCRATCH FIELD A  gc_ref 1 B  gc_ref 1 C  gc_ref 2 C is also held by a live local variable STEP 2 · SUBTRACT ONE PER REFERENCE HELD FROM INSIDE A → B, B → A, C → A so B loses 1, A loses 2, C loses 0 STEP 3 · WHAT REMAINS IS REFERENCES FROM OUTSIDE A  gc_ref 0 B  gc_ref 0 C  gc_ref 1 A AND B: COLLECTED C AND ITS REFERENTS: SURVIVE
The scratch count is the whole trick: subtract the references the group holds on itself, and whatever is left came from outside.

When does garbage collection run?

Here is the part that surprises people, and the part everything later in this post depends on: collection is triggered by allocation, not by time and not by memory pressure. CPython keeps a running count of container allocations minus deallocations, and when that count crosses a threshold, it collects.

It also assumes that most objects die young, so it sorts objects into generations and scans the youngest one far more often than the others.

GenerationThresholdWhat it holdsCost of a pass
Young2000Objects allocated since the last collectionSmall, if it runs often
Old, pending10Survivors awaiting a full examinationModerate
Old, visited0Long-lived objects, scanned incrementallyLargest

The important consequence is that a young-generation pass is only cheap because it runs frequently. Every collection it skips is objects that pile up for the next one to traverse, and the cost of a pass scales with how much survived to be walked. A collector that falls behind does not degrade gracefully, it degrades quadratically in perceived pain: longer gaps, bigger heaps, longer pauses.

Thread-local allocation counting

Python 3.13 shipped a free-threaded build, which removes the global interpreter lock and lets threads execute Python bytecode genuinely in parallel. That is exactly what you want in an inference server, where the alternative is process-per-worker and a copy of the model per process.

It also complicates the allocation counter. A single global count incremented on every allocation would be a contended atomic in the hottest path in the interpreter, which reintroduces by the back door the serialization the GIL removal was meant to eliminate. CPython's answer is batching: each thread keeps its own local count and only touches the shared one when the local count crosses LOCAL_ALLOC_COUNT_THRESHOLD, which is 512.

SIMPLIFIED FROM Python/gc_free_threading.c

#define LOCAL_ALLOC_COUNT_THRESHOLD 512

/* One of these per thread, no atomics in the common case */
static void record_allocation(PyThreadState *tstate)
{
    struct _gc_thread_state *gc = &((_PyThreadStateImpl *)tstate)->gc;
    gc->alloc_count++;

    if (gc->alloc_count >= LOCAL_ALLOC_COUNT_THRESHOLD) {
        /* flush the batch into the shared counter */
        add_to_shared_count(gc->alloc_count);
        gc->alloc_count = 0;
    }
}

static void record_deallocation(PyThreadState *tstate)
{
    struct _gc_thread_state *gc = &((_PyThreadStateImpl *)tstate)->gc;
    gc->alloc_count--;

    if (gc->alloc_count <= -LOCAL_ALLOC_COUNT_THRESHOLD) {
        add_to_shared_count(gc->alloc_count);   /* a NEGATIVE batch */
        gc->alloc_count = 0;
    }
}

This is a good design. Allocation stays cheap, the shared counter is touched once per 512 events per thread instead of once per event, and the count it holds is accurate to within 512 per thread, which is plenty for deciding when to run a collection.

Notice the asymmetry in that pair of functions, and compare it with what the build that still has the GIL does when an object goes away. There, the decrement is guarded:

SIMPLIFIED FROM Python/gc.c, THE BUILD WITH THE GIL

if (gcstate->young.count > 0) {
    gcstate->young.count--;      /* never goes below zero */
}

With one thread and one counter, that guard is all it takes to keep the number that gates collection non-negative. With per-thread batching there are two counters, and the question of where to put the floor becomes a real design decision.

Why the shared count can go negative

You can ask the interpreter for its own view of that state at any time, and the young generation's number is the one to read first:

INSPECTING THE COUNTERS

>>> import gc
>>> gc.get_count()
(1843, 0, 0)      # young, old-pending, old-visited

>>> gc.get_count()
(-2048, 0, 0)     # a deficit: 2048 allocations before zero,
                  # and 2000 more before a collection runs

The young generation's count is meant to be the number of objects allocated since the last collection, compared against a threshold of 2000. A negative value is therefore not a small inaccuracy, it is a deficit that has to be paid off in allocations before the threshold is even in reach. Collection is delayed by exactly that much.

The arithmetic that produces one takes several threads, which is why it is specific to the free-threaded build.

How four threads drive the shared allocation count to minus 2048 Four steps. First, four threads each hold a local count of 300 and the shared count is at the threshold. Second, a collection runs and resets the shared count to zero, leaving the local counts untouched. Third, each thread frees 812 objects, taking its local count to minus 512. Fourth, each thread flushes minus 512 into the shared counter with no clamp, leaving the shared count at minus 2048 and collection unable to trigger. 1 · STEADY STATE T1: 300 T2: 300 T3: 300 T4: 300 local counts SHARED: 2000, COLLECT 2 · COLLECTION RESETS THE SHARED COUNT ONLY T1..T4 LOCALS UNCHANGED, STILL 300 EACH SHARED: 0 3 · EACH THREAD FREES 812 OBJECTS T1: -512 T2: -512 T3: -512 T4: -512 300 - 812 = -512, threshold hit 4 · ALL FOUR FLUSH, WITHOUT A FLOOR -512 × 4 SHARED: -2048 NOW 2,048 ALLOCATIONS JUST TO REACH ZERO, AND 2,000 MORE TO COLLECT. REPEAT THE CYCLE AND THE DEFICIT COMPOUNDS INTO THE MILLIONS.
This is not a race in the usual sense. Every step is correct in isolation, and the deficit is the sum of four legitimate flushes.

Two details make a deficit compound rather than self-correct. A collection zeroes the shared counter but not the per-thread ones, so the accounting starts each interval already skewed. And because the deficit makes the next collection later, there is more time to accumulate the next one before anything resets. Enough rounds of that and the shared count is in the millions rather than the thousands.

Where to put the floor

The thread-local count going negative is fine and should stay legal: a thread that frees more than it allocates is doing real work and its local number is honest bookkeeping. What matters is that a negative value not land in the shared counter that gates collection. So the floor belongs on the flush, not on the decrement.

Because several threads can flush at the same moment, the clamp cannot be a read followed by a write. It needs a compare-and-exchange loop, so that a thread which loses the race retries against the value that actually landed:

CLAMP THE SHARED COUNT, NOT THE LOCAL ONE

/* Flush a thread's local batch into the shared count, floored at zero */
Py_ssize_t old = _Py_atomic_load_ssize_relaxed(&shared_count);
Py_ssize_t new_count;

do {
    new_count = old + local_batch;
    if (new_count < 0) {
        new_count = 0;        /* the gate never goes negative */
    }
} while (!_Py_atomic_compare_exchange_ssize(&shared_count,
                                            &old, new_count));

/* On failure, `old` holds the value another thread wrote, so the
   loop recomputes against reality instead of clobbering it. */

A few lines, and worth noticing what the compare-and-exchange buys. With a plain load, clamp, store, two threads flushing simultaneously can each read the same starting value, and one write wins while the other thread's batch disappears. The counter stays non-negative and stays wrong.

Why collection frequency is the number to watch

The practical consequence of everything above is visible with gc.set_debug(gc.DEBUG_STATS), which prints a line per collection. Compare a collector running on schedule with one that has fallen a long way behind on the same workload.

A COLLECTOR THAT HAS FALLEN BEHIND

gc: collecting generation 0...
gc: objects in each generation: 4892745 0 0
gc: done, 3.127s elapsed

THE SAME WORKLOAD, COLLECTING ON SCHEDULE

gc: collecting generation 0...
gc: objects in each generation: 2145 0 0
gc: done, 0.001s elapsed

PAUSE PER COLLECTION

3.127 s against 0.001 s. A factor of roughly 3,000, and the difference between a p99 nobody notices and one that shows up in a client's own monitoring.

OBJECTS TRAVERSED

4,892,745 against 2,145. The long pause is not the collector being slow. It is the collector being asked to walk a heap that grew for minutes instead of milliseconds.

WHAT DIFFERS

Not the collection algorithm, and not the program's allocation behavior. Only how often the collector was reached by the trigger.

This is the whole argument for the generational design, and its one condition. A young-generation pass is cheap because it runs often; whatever delays the trigger converts that cheap frequent pass into an expensive rare one.

Four things worth remembering

Read gc.get_count() before profiling a latency problem. If any of the three numbers is negative or implausibly large, the scheduling of collection is the thing to look at first; a profiler will otherwise just confirm that time went into whatever happened to run during the pause.

Watch collection frequency, not only pause duration. A collector that has stopped being triggered looks healthy on a pause-time dashboard, right up until the pause that does happen is measured in seconds. Frequency is the leading indicator.

Allocation is the clock. Not wall time, not resident memory. A workload that allocates in bursts and then goes quiet can hold garbage indefinitely, and one that allocates constantly collects constantly.

Per-thread bookkeeping has different arithmetic. Removing the GIL means every counter that assumed one thread of execution gets reimplemented as a batched, per-thread version. Those are correct in isolation and interact in ways the single-threaded version never could, which is exactly where a floor on one counter and a reset on another have to be reasoned about together.

Where this fits at Numerata

Runtime behavior at this level is what NinetyFive, the serving layer in the Numerata stack, is built on top of, and it is the sort of detail that only becomes visible when you own the serving path down to the interpreter build, the same way owning the CUDA graph capture is what makes warmup time addressable. It is also why the sizing arithmetic holds up in practice: a p99 target is only meaningful if the runtime underneath it is collecting on a schedule you can predict.

The original writeup, with more of the CPython source detail, is on the NinetyFive engineering blog at ninetyfive.gg/blog/python-gc ↗.

Numerata runs inside your own environment: private cloud, on-prem, or fully air-gapped.  ·  Back to blog