22 — Garbage collection: the cycle detector, walked line by line¶
Tier 3, doc 22. Prerequisites:
15-refcounting-and-ownership.md(owned vs borrowed refs,Py_DECREF),16-object-memory-layout.md(PyGC_Head, which objects carry one),01-memory-hierarchy-and-caches.md§10 (pointer chasing, cache lines). Feeds into:26-free-threading.md,32-profiling.md,35-memory-optimization.md,46-production-python.md.THESIS: CPython does not have "a garbage collector." It has a deallocator (reference counting) that handles ~all objects, plus a cycle detector that exists solely to fix refcounting's one structural blind spot. Every property people find surprising — the generations, the thresholds,
__del__ordering, resurrection,gc.freeze(), the stop-the-world pauses on free-threaded builds, and the fact that a shipped collector was un-shipped in a patch release in May 2026 — falls out of that one sentence. And because the cycle detector's core loop is pointer chasing across the entire live heap, a GC pause costs you the pause plus the cold cache afterwards.Measurement provenance. Every number labelled (measured) was produced on the machine this repo lives on: Apple M3 Pro, macOS, arm64, 128-byte cache lines, 16 KB pages, 5 P-cores + 6 E-cores, using CPython 3.14.6 (
~/.local/bin/python3.14) and the 3.14.6 free-threading build (~/.local/bin/python3.14t,sys._is_gil_enabled()→False). Noperf(1)on this box, so §11 reaches an honestly inconclusive result and says so rather than inventing one. C source excerpts are quoted from the3.14branch of github.com/python/cpython as of Aug 2026 and were downloaded and read, not recalled. Anything I could not verify is flagged in place.
Contents¶
- Two mechanisms, and why there must be two
- Where the code actually lives
- Which objects are GC-tracked
- The algorithm, walked on a concrete 4-object graph
- Generations, thresholds, and the 2000 that used to be 700
tp_traverseandtp_clear: the C extension contract- Finalizers:
__del__, PEP 442, and resurrection - Weakrefs and callback ordering
- The incremental GC saga
- Free-threaded GC: two stop-the-world pauses
- GC as a cache-hostility problem
- Practical tuning and leak hunting
- Lab exercises
- Question bank
- Sources
1. Two mechanisms, and why there must be two¶
Mechanism one: reference counting. Every PyObject starts with ob_refcnt at offset
0 (16-object-memory-layout.md §1). When it reaches zero,
tp_dealloc runs immediately, on the thread that dropped the last reference. This is
prompt, deterministic, incremental, and it handles the overwhelming majority of objects.
It is also the reason the GIL exists (24-the-gil.md §1).
Its one structural failure: cycles. If A references B and B references A, and nothing else references either, both counts are 1 forever. Neither will ever hit zero. Refcounting cannot detect this locally — that is the entire point of a local algorithm.
(measured) — the whole doc in eight lines:
$ python3.14 lab_cycle.py
refcount a before del: 2
after del, gc disabled: wa()=Node(a) wb()=Node(b) ← still alive. leaked.
gc.collect() freed objects: 2
after gc.collect(): wa()=None wb()=None ← the cycle detector got them
acyclic after del, no gc: wc()=None wd()=None ← refcounting handled this alone
Mechanism two: the cycle detector, a mark-and-sweep-flavoured tracing collector that runs only over container objects and only occasionally. Note what it is not: it is not a general tracing GC. It never frees a non-cyclic object — refcounting got there first. It exists to answer exactly one question: which of these containers form a group whose only remaining references are to each other?
This division has a consequence people miss: the cycle detector's cost is not proportional to your garbage. It is proportional to your live heap. A program with 50 million live objects and zero cycles still pays for full collections that walk all 50 million. That asymmetry drives §5, §9, §11, and §12.
2. Where the code actually lives¶
This moved recently, and quoting the old path marks you as reading a stale blog post. Verified by fetching each path from the CPython git branches (verified Aug 2026):
| Path | 3.12 | 3.13 | 3.14 | What it is |
|---|---|---|---|---|
Modules/gcmodule.c |
✅ (everything) | ✅ | ✅ | now just the gc module wrapper |
Python/gc.c |
❌ 404 | ✅ | ✅ (2057 lines) | the collector, GIL builds |
Python/gc_free_threading.c |
❌ 404 | ✅ | ✅ (3006 lines) | the collector, free-threaded builds |
Include/internal/pycore_gc.h |
✅ | ✅ | ✅ | flags, gc_refs accessors, tracking predicates |
Include/internal/pycore_interp_structs.h |
— | — | ✅ | PyGC_Head, _gc_runtime_state, thresholds |
InternalDocs/garbage_collector.md |
✅ | ✅ | ✅ | the design doc — read it |
The header of Modules/gcmodule.c on 3.14 says it itself:
/*
* Python interface to the garbage collector.
*
* See Python/gc.c for the implementation of the garbage collector.
*/
Note the fork at Python/gc.c vs Python/gc_free_threading.c: there are two complete,
separately-maintained collectors in the tree, selected by Py_GIL_DISABLED. That is a
real maintenance cost and it is why §9's "just keep both collectors around as an option"
proposal was contentious.
3. Which objects are GC-tracked¶
Only types with Py_TPFLAGS_HAVE_GC allocate a PyGC_Head and can be tracked. On GIL
builds the head is 16 bytes sitting before the PyObject*; on free-threaded builds it
is gone entirely and the state lives in mimalloc page metadata plus an ob_gc_bits byte
(16-object-memory-layout.md §2, §12):
/* Include/internal/pycore_interp_structs.h — GC information is stored BEFORE
the object structure. */
typedef struct {
uintptr_t _gc_next; // Tagged pointer to next object in the list.
// 0 means the object is not tracked
uintptr_t _gc_prev; // Tagged pointer to previous object in the list.
// Lowest two bits are used for flags documented later.
} PyGC_Head;
(measured, 3.14.6):
| Expression | gc.is_tracked |
Why |
|---|---|---|
1, 1.5, "x", object() |
False | type lacks Py_TPFLAGS_HAVE_GC — cannot reference anything |
[], {}, set(), frozenset([1]) |
True | |
{1: 2} |
True | see the dict note below |
| instance of a plain class | True | |
a lambda |
True | closures and __globals__ |
tuple([1, 2]) |
False | born untracked — see below |
tuple(range(2)) |
True, then False after gc.collect() |
born tracked, untracked at collection |
(1, 2) as a code constant |
True when fresh, False after a collection | same object every LOAD_CONST |
The tuple untracking optimization — and a 3.14 surprise¶
Two separate mechanisms untrack tuples, and conflating them produced the inconsistent readings above.
(a) Collection-time untracking. untrack_tuples() runs over the young generation on
every collection and calls _PyTuple_MaybeUntrack:
/* Objects/tupleobject.c */
void
_PyTuple_MaybeUntrack(PyObject *op)
{
if (!PyTuple_CheckExact(op) || !_PyObject_GC_IS_TRACKED(op))
return;
for (i = 0; i < n; i++) {
PyObject *elt = PyTuple_GET_ITEM(t, i);
if (!elt || _PyObject_GC_MAY_BE_TRACKED(elt))
return; /* something in here could still form a cycle */
}
_PyObject_GC_UNTRACK(op); /* permanently off the collector's list */
}
A tuple whose contents cannot themselves be tracked can never be part of a cycle, so it is
removed from every future traversal. The pycore_gc.h comment is explicit that this is
best-effort: "It may take more than one cycle to untrack a tuple" — because the C API
lets you create a tuple and fill it in afterwards.
(b) Birth-time untracking (3.14). _PyTuple_FromArray, _PyTuple_FromArraySteal and
PyTuple_Pack never track in the first place if no element's type is GC-capable:
bool track = false;
for (Py_ssize_t i = 0; i < n; i++) {
PyObject *item = src[i];
if (!track && maybe_tracked(item)) {
track = true;
}
...
}
if (track) {
_PyObject_GC_TRACK(tuple);
}
That is why tuple([1,2]) (which goes through PyList_AsTuple → _PyTuple_FromArray)
reads False immediately, while tuple(range(2)) (built incrementally through
PyTuple_New) reads True until the next collection. The observable answer to
"is this tuple tracked?" depends on the construction path. Do not build a mental model
on gc.is_tracked of a literal in a REPL.
Dicts changed in 3.14 — the stdlib docs are stale¶
The gc module docs still show gc.is_tracked({}) → False. On 3.14.6 (measured) it
is True. Per InternalDocs/garbage_collector.md:
Dictionaries are always tracked from creation and are not untracked by the garbage collector. Earlier versions (up to 3.13) used lazy tracking… That machinery was removed in 3.14 (GH-127010) because the per-set-item cost of checking the tracking invariant outweighed the savings on full collections.
This is a clean example of a GC trade-off flipping sign: the check was cheap per
collection and expensive per __setitem__, forever. Removing it makes every dict
insertion faster and every full collection slightly slower.
4. The algorithm, walked on a concrete 4-object graph¶
This is the section everyone hand-waves. Here is the whole thing, on four real objects, with the real function names, verified against a live interpreter.
4.1 Where gc_refs lives¶
There is no separate gc_refs field. It is packed into the top bits of _gc_prev,
whose bottom two bits are flags:
/* Include/internal/pycore_gc.h */
#define _PyGC_PREV_MASK_FINALIZED ((uintptr_t)1) /* tp_finalize was called */
#define _PyGC_PREV_MASK_COLLECTING ((uintptr_t)2) /* in the generation being GCed */
#define _PyGC_PREV_SHIFT 2
/* Python/gc.c */
static inline Py_ssize_t gc_get_refs(PyGC_Head *g)
{ return (Py_ssize_t)(g->_gc_prev >> _PyGC_PREV_SHIFT); }
static inline void gc_decref(PyGC_Head *g)
{ g->_gc_prev -= 1 << _PyGC_PREV_SHIFT; }
So during a collection _gc_prev stops being a pointer and becomes a counter; the
list is temporarily singly-linked through _gc_next, and move_unreachable restores the
back-pointers on its way out. That is a 16-byte-per-object saving paid for with a very
delicate invariant. _gc_next's low bit also carries NEXT_MASK_UNREACHABLE.
4.2 The graph¶
A = [None] # a list, also bound to a local name → 1 external ref
B = Obj(); A[0] = B; B.ref = A # A ↔ B cycle, but A is externally reachable
C = Obj(); D = Obj(); C.ref = D; D.ref = C # C ↔ D pure trash cycle
(measured) — sys.getrefcount minus the call's own temporary: A: 2, B: 1, C: 1, D: 1,
and gc.collect() returns 2, freeing exactly C and D.
4.3 The walk¶
gen0 list A(list) B(Obj) C(Obj) D(Obj)
────────────────────────────────────────────────────────────────────────────────
real refs to it local name A[0] D.ref C.ref
+ B.ref
ob_refcnt 2 1 1 1
STEP 1 — update_refs(): gc_refs := ob_refcnt, set PREV_MASK_COLLECTING on all
────────────────────────────────────────────────────────────────────────────────
gc_refs [2] [1] [1] [1]
(immortal objects are UNTRACKED here and skipped entirely — PEP 683)
STEP 2 — subtract_refs(): for each object, tp_traverse(op, visit_decref)
visit_decref decrements the referent's gc_refs, but ONLY if the
referent is in this generation (gc_is_collecting())
────────────────────────────────────────────────────────────────────────────────
A ──traverse──▶ B B: 1 → 0
B ──traverse──▶ A A: 2 → 1
C ──traverse──▶ D D: 1 → 0
D ──traverse──▶ C C: 1 → 0
gc_refs [1] [0] [0] [0]
▲ ▲
│ └── "unreachable *so far*" — not proven
└── 1 reference from OUTSIDE the set. A is definitely alive.
STEP 3 — move_unreachable(young, unreachable): single left-to-right scan
────────────────────────────────────────────────────────────────────────────────
visit A: gc_refs=1 > 0 → REACHABLE. Keep in young, clear COLLECTING,
restore _gc_prev, then tp_traverse(A, visit_reachable):
sees B with gc_refs == 0 → gc_set_refs(B, 1)
("resurrect" into young)
visit B: gc_refs=1 > 0 → REACHABLE. traverse → A, but A is no longer
COLLECTING, so visit_reachable ignores it.
visit C: gc_refs=0 → move to `unreachable`, set NEXT_MASK_UNREACHABLE
visit D: gc_refs=0 → move to `unreachable`
────────────────────────────────────────────────────────────────────────────────
young = [ A , B ] ← survivors, promoted to the next generation
unreachable = [ C , D ] ← cyclic trash
The two subtleties that make this work:
Why gc_refs == 0 does not mean "dead". It means "no references from outside the set
that we have found yet". B had gc_refs == 0 after step 2 and is very much alive. The
proof only completes when the scan finishes — this is a breadth-first closure, and
visit_reachable can pull an object back out of the unreachable list mid-scan:
if (gc->_gc_next & NEXT_MASK_UNREACHABLE) {
/* This had gc_refs = 0 when move_unreachable got to it, but turns
* out it's reachable after all. Move it back to move_unreachable's
* 'young' list, and move_unreachable will eventually get to it again. */
...
gc_list_append(gc, reachable);
gc_set_refs(gc, 1);
}
Why it moves the unreachable objects rather than the reachable ones, when most
objects are reachable — the comment in deduce_unreachable() is one of the best in the
tree:
The key is that this dance leaves the objects in order C, B, A — it's reversed from the original order. On all subsequent scans, none of them will move. Since most objects aren't in cycles, this can save an unbounded number of moves across an unbounded number of later collections. It can cost more only the first time the chain is scanned.
An optimization that costs more on the first pass and zero on every pass thereafter. That is the shape of most real GC engineering.
4.4 What happens to unreachable after that¶
gc_collect_main() then runs, in order:
untrack_tuples()— §3.move_legacy_finalizers()— objects with a non-NULLtp_del(pre-PEP-442) are pulled out, along with everything reachable from them (move_legacy_finalizer_reachable), and end up ingc.garbage. §7.handle_weakrefs()— clear weakrefs, queue callbacks. §8.finalize_garbage()— calltp_finalize(i.e.__del__) on each object exactly once.handle_resurrected_objects()— a seconddeduce_unreachable()pass, because a finalizer may have stored a reference somewhere live. §7.delete_garbage()— calltp_clearon what is still unreachable, breaking the cycles so refcounting can finish the job.
Steps 1–3 and 5 are the reason PEP 703 needs two stop-the-world pauses, not one (§10).
5. Generations, thresholds, and the 2000 that used to be 700¶
The weak generational hypothesis: most objects die young. So CPython keeps three doubly-linked lists and collects the young one often.
/* Include/internal/pycore_interp_structs.h */
#define NUM_GENERATIONS 3
#define GC_GENERATION_INIT \
.generations = { \
{ .threshold = 2000, }, /* gen 0: allocations − deallocations */ \
{ .threshold = 10, }, /* gen 1: gen-0 collections since last */ \
{ .threshold = 10, }, /* gen 2: gen-1 collections since last */ \
},
(measured, 3.14.6, both builds):
That first number was 700 for two decades. Verify the history before repeating it —
I did: Include/internal/pycore_runtime_init.h on the 3.12 branch has
{ .threshold = 700, }; the 3.13 branch has { .threshold = 2000, }. So the change
landed in 3.13, not 3.14, and it is a side-effect of the incremental-GC episode (§9):
the threshold was raised to 5000 in 3.13 alpha ⅚ as part of the incremental work, and
when the incremental collector was ripped back out days before 3.13.0, the tuning survived
at 2000. Neil Schemenauer, on the thread, September 2024:
The non-incremental GC is quite aggressively tuned, with the youngest generation threshold at 700. That makes it safe in terms of quickly freeing resources involving cyclic garbage but it also means that it often does more work than required. … The current value is 700 and that was set many years ago when compute[rs were different]
His measured Sphinx numbers on that thread are the argument in one table (threshold →
time, max RSS): 700 → 2.59 s / 87 MB; 5,600 → 1.78 s / 87 MB; 70,000 → 1.70 s / 93 MB;
700,000 → 1.71 s / 122 MB. Almost all the speed is available before RSS starts moving.
The counter-anecdote in the same thread is the one to remember: Itamar Turner-Trauring
reported that applying Meta's well-tested (14_000, 100, 100) to all Python workloads
"caused at least a dozen services to start crashing with OOMs."
(measured) — watching promotion happen, allocating 2000 tracked objects per step and keeping them all alive:
allocs count gen0 gen1 gen2 ← cumulative collections
2000 (12, 1, 0) 3 0 3
6000 (11, 3, 0) 5 0 3
20000 (4, 10, 0) 12 0 3
40000 (1995, 7, 1) 20 1 3 ← gen1 count hit 10 → gen1 collect
80000 (1975, 3, 3) 38 3 3
Gen 2 never runs, despite 38 gen-0 collections and 3 gen-1 collections. That is the long-lived-pending heuristic, and it is the single most important tuning fact in the collector:
/* Python/gc.c — gc_select_generation() */
if (i == NUM_GENERATIONS - 1
&& gcstate->long_lived_pending < gcstate->long_lived_total / 4)
{
continue; /* skip the full collection */
}
A full collection only runs if at least 25% of the long-lived population is "pending" — has survived non-full collections but never been through a full one. The rationale, from the source, is Martin von Löwis's 2008 analysis:
…the cost of a full collection is proportional to the total number of long-lived objects, which is virtually unbounded. … "each full garbage collection is more and more costly as the number of objects grows, but we do fewer and fewer of them."
Without it, building a large list of tracked objects is quadratic. With it, amortized linear. Remember this shape — §9 is what happens when the equivalent heuristic in a new collector gets the arithmetic wrong.
6. tp_traverse and tp_clear: the C extension contract¶
The collector cannot see into your C struct. It calls two slots, and if you implement them wrong you get either leaks or crashes — nothing in between.
tp_traverse(self, visit, arg) must call Py_VISIT(field) on every PyObject*
the object owns a strong reference to. That is the entire contract, and both directions of
violating it are bad:
- Miss a field → the collector under-counts internal references, so
subtract_refsleavesgc_refs > 0on a genuinely dead object, and the cycle is never collected. A silent, permanent leak thattracemallocwill happily attribute to your extension's allocation site with no hint of why. - Visit a field you don't own (a borrowed reference) → the collector over-subtracts,
gc_decrefcan drivegc_refsbelow zero, and CPython will free a live object. In a debug build you get_PyObject_ASSERT_WITH_MSG(op, gc_refs > 0, "refcount is too small")— which you can see invisit_reachableandgc_decrefin the excerpts above. In a release build you get a use-after-free at a random later point.
tp_clear(self) must drop strong references (Py_CLEAR) so the cycle breaks. It is
called from delete_garbage():
inquiry clear;
if ((clear = Py_TYPE(op)->tp_clear) != NULL) {
Py_INCREF(op);
(void) clear(op);
...
Py_DECREF(op);
}
if (GC_NEXT(collectable) == gc) {
/* object is still alive, move it, it may die later */
gc_clear_collecting(gc);
gc_list_move(gc, old);
}
Note the Py_INCREF/Py_DECREF bracket: tp_clear runs on a live object and may
trigger arbitrary deallocation, including of the object itself, so the collector keeps a
reference across the call and then checks whether the object survived.
Three rules that follow:
tp_traversemust be pure. It runs mid-collection with_gc_prevholding a counter instead of a pointer. Allocating, calling back into Python, or raising will corrupt or crash the collector.- A type with
Py_TPFLAGS_HAVE_GCmust implementtp_traverse.subtract_refscalls it unconditionally — there is no NULL check. - Heap types must visit
Py_TYPE(self). Since 3.9 a heap type is a strong reference from the instance, and instance→type→module→instance is a real cycle. Forgetting this is the most common leak inpybind11/hand-written extension modules.
7. Finalizers: __del__, PEP 442, and resurrection¶
Before PEP 442 (Python ≤ 3.3): cycles with __del__ were uncollectable¶
The old slot was tp_del. The collector could not order finalizer calls within a cycle,
and calling tp_del on an object whose cycle-mates had already been tp_cleared would
hand user code a half-destroyed object. So it refused: any cycle containing an object with
tp_del went to gc.garbage and leaked, permanently. "Never write __del__" was
correct advice for a decade.
PEP 442 (Python 3.4): tp_finalize, and a bit in the GC header¶
PEP 442 split finalization from deallocation. __del__ now maps to tp_finalize, and
the collector runs finalizers before breaking anything:
/* Python/gc.c — finalize_garbage() */
if (!_PyGC_FINALIZED(op) &&
(finalize = Py_TYPE(op)->tp_finalize) != NULL)
{
_PyGC_SET_FINALIZED(op);
Py_INCREF(op);
finalize(op);
assert(!_PyErr_Occurred(tstate));
Py_DECREF(op);
}
_PyGC_SET_FINALIZED sets _PyGC_PREV_MASK_FINALIZED — bit 0 of _gc_prev (§4.1), or
_PyGC_BITS_FINALIZED in ob_gc_bits on free-threaded builds. The object is finalized
at most once, ever. From the PEP:
On the internal side, a bit is reserved in the GC header for GC-managed objects to signal that they were finalized. This helps avoid finalizing an object twice (and, especially, finalizing a CT object after it was broken by the GC).
Every object in the trash gets tp_finalize called before any of them gets tp_clear,
so every finalizer sees an intact graph. Ordering among the finalizers is still
unspecified — and it has to be; there is no defensible order in a cycle.
(measured, 3.14.6):
--- PEP 442: a cycle whose members define __del__ ---
before collect: gc.garbage = []
__del__ ran for a
__del__ ran for b
collect() returned 2 gc.garbage = []
gc.garbage is empty. That would have been two permanently leaked objects on Python 3.3.
tp_del still exists for compatibility, but per the PEP "a non-NULL tp_del is not
encountered anymore in the CPython source tree (except for testing purposes)." In
practice gc.garbage non-empty today means a third-party C extension.
Resurrection, and its exact rules¶
A finalizer receives a live object and may store it somewhere reachable. That is resurrection. (measured — note the return value):
collect#1 -> 0 | __del__ calls: ['a', 'b']
saved: <__main__.Lazarus object at 0x103894590> | gc.is_finalized(saved): True
saved.other is still alive?: <__main__.Lazarus object at 0x103874690>
collect#2 -> 2 | __del__ calls now: ['a', 'b'] <- 'a' NOT repeated
Read that first line: gc.collect() returned 0, not 2. The cycle was proven
unreachable, the finalizers ran, one of them stashed self in a global — and the second
deduce_unreachable() pass in handle_resurrected_objects() found both objects
reachable again and merged them into the old generation. Nothing was freed. That second
pass is not defensive programming; it is load-bearing.
The rules, precisely:
- Resurrecting one member of a cycle resurrects the whole cycle.
saved.otheris alive above — the collector cannot un-break a graph selectively. tp_finalizewill never be called again on that object.gc.is_finalized(saved)isTrueand stays true. Drop the reference, collect again: freed silently, no second__del__.- Resurrected objects are moved to the oldest generation (
gc_list_merge(resurrected, old_generation)), so they will not be re-examined for a long time. - A
__del__on a resurrected object cannot be relied upon to release a resource. It fires once, at a time you do not control, possibly during interpreter shutdown when module globals are alreadyNone.
Why weakref.finalize is usually correct¶
import weakref
class Conn:
def __init__(self, sock):
self.sock = sock
self._fin = weakref.finalize(self, sock.close) # not a __del__
weakref.finalize beats __del__ on every axis that matters:
- The callback does not hold a strong reference to the object, so registering it does not keep the object alive and does not make the object's type harder to collect.
- It cannot resurrect — the callback gets whatever arguments you bound, not
self. - It runs exactly once, and you can query
.aliveand force it with.detach()/ calling the finalizer object. - It is guaranteed to run at interpreter exit by default (
atexit=True), which__del__is not. - (measured): on a two-object cycle,
weakref.finalizefired andfin.alivebecameFalse. It works fine on cyclic garbage.
The remaining legitimate uses of __del__ are: a last-resort "you forgot to close()"
ResourceWarning, and C-level types where you're implementing tp_finalize anyway.
Everything else should be a context manager first, weakref.finalize second.
8. Weakrefs and callback ordering¶
Weakrefs interact with the collector in a way that has one rule worth memorizing.
/* Python/gc.c — handle_weakrefs()
* Note that we cannot invoke any callbacks until all weakrefs to unreachable
* objects are cleared, lest the callback resurrect an unreachable object via a
* still-active weakref. */
So the order is: clear every weakref to the trash first; only then invoke callbacks.
A callback that calls wr() gets None, always. This closes a resurrection hole that
tp_finalize's once-only bit does not cover.
The rule: if a weakref is itself part of the trash cycle, its callback is not called. (measured):
weakref inside the trash cycle -> callbacks fired: NONE
weakref outside the trash cycle -> callbacks fired: ['OUTSIDE callback']
The reasoning is the same as for finalizer ordering: the callback is about to be
destroyed too, and there is no meaningful order in which to run callbacks that are
themselves garbage. The comment in Python/gc.c puts it as "it's possible for such
weakrefs to be outside the unreachable set — indeed, those are precisely the weakrefs
whose callbacks must be invoked."
Production consequence: an observer/cache built on WeakValueDictionary whose
callback does cleanup will silently skip that cleanup for any entry that ends up inside a
cycle with the dictionary. If the cleanup is important (releasing an fd, decrementing a
counter), that's a slow leak that only appears under the exact object shapes that create
the cycle. WeakValueDictionary and WeakSet handle their own internal case correctly;
your callback on top of them is what breaks.
9. The incremental GC saga¶
This is the best worked example in the whole roadmap of why GC design is hard, and it is
the reason README §15 says version facts rot. A feature shipped in .0 and was
un-shipped in .5 of the same release series. Timeline, verified against primary
sources (confidence notes at the end of this section):
| When | What | Source |
|---|---|---|
| 3.13 alphas (2024) | Mark Shannon's incremental collector merged. Two generations (young/old); each collection does a fraction of the old space. Young threshold raised 700 → 5000, later 2000. | CPython git history; pycore_runtime_init.h diff 3.12 → 3.13 |
| Sept 2024 | Alex Waygood et al. trace a large Sphinx slowdown to it (gh-124567). | discuss.python.org t/65285 |
| 28 Sept 2024 | Release manager Thomas Wouters: "I don't think we should release 3.13.0 with the incremental GC." Rolls it back, cuts rc3 on 30 Sept, and pushes 3.13.0 final back a week to 7 Oct 2024. | discuss t/65285 |
| 7 Oct 2025 | 3.14.0 ships with the incremental collector. get_threshold() now returns (2000, 10, 0): value 1 is the young threshold, value 2 is the old-space scan rate, value 3 is meaningless. |
whatsnew 3.14 |
| 10 Dec 2025 | gh-142516 — "Observed memory leak in ssl library: Python 3.14 GC issue". Reporter's chain: MSAL → requests → urllib3 → ssl.SSLContext.load_verify_locations. Memray traces attached. |
GitHub |
| 20 Apr 2026 | Adam Johnson publishes a Django reproduction: migrate on a Heroku dyno with a low memory cap; workaround is forcing gc.collect() after each migration. |
adamj.eu |
| 16 Apr 2026 | Hugo van Kemenade announces the revert in both 3.14 and 3.15, back to the 3.13 generational collector. | discuss t/107014 |
| 23 Apr 2026 | Tim Peters posts the minimal reproduction and the mechanism (below). | discuss t/107067 |
| 10 May 2026 | 3.14.5 ships the revert. | blog.python.org |
| Aug 2026 | 3.14.6 and 3.15 are on the generational collector. Reintroduction for 3.16 is under discussion, via the PEP process, most likely opt-in with the old collector as default. | discuss t/107014, t/107067 |
The mechanism of the failure¶
Tim Peters' toy: an infinite loop that creates one cycle per iteration and, after iteration 1000, converts one to trash per iteration. Never more than 1000 reachable cycles. Under 3.13's generational collector, a gen-0 collection fires around iteration 2000, reclaims 1000 trash cycles, promotes 1000, and repeats smoothly at 4000, 6000, 8000. Under 3.14's incremental collector:
Nothing is collected at 2000 iterations. And still not by 4000 iterations. Or 6000, 8000, … Nothing at all gets collected until about the 20 thousandth iteration. gc is invoked along the way … but it returns without collecting anything until iteration 20_000. Then it collects about 18_000 trash cycles. … It eventually (after about 750K iterations) reaches a "steadyish state", always with over 90K trash cycles awaiting collection, but not more than 100K.
…I don't understand the current "work to do" logic, and especially not how "the math" can end up making it negative(!) at times. But intuition says "work to do" should always include gen0.
Neil Schemenauer's independent finding, on the same threads: "process memory use can be dramatically higher (5x was the worst case I saw) and runtime is slower" — while confirming the incremental collector genuinely did deliver smaller maximum pauses.
His proposed fix is one sentence: "we trigger GC every 2000 net new objects, like the generational GC. We size the increments (how many old objects to look at) such that we effectively do a full collection often enough." His prototype kept max RSS and trash count low while preserving the short pauses. The core team and the Steering Council still chose the full revert, because — Hugo's words — "the old GC is a known quantity, the new incremental GC didn't go through the PEP process."
The five transferable lessons¶
- "Fewer objects scanned per pause" is not "less memory." The incremental collector optimized the pause-time metric perfectly and let the backlog metric run free. If your GC change has one number attached to it, you have not evaluated it.
- A latency win that is a throughput-and-memory loss is a trade, not an improvement, and the default must be chosen for the workload you can't see.
- CPython has no benchmark for this. Neil, April 2026: "The pyperformance suite contains basically no interesting benchmarks in terms of exercising the cyclic GC in a realistic way." The failure was found by a synthetic toy and by production users, in that order — not by CI.
- Reverts have blast radius too. Tim Peters: users who spent real effort tuning
gc.set_threshold()for the incremental collector had that effort silently invalidated by 3.14.5. - The fossils are in the struct.
_gc_runtime_stateon the 3.14 branch today:
/* dummy members to preserve other offsets */
Py_ssize_t dummy1; /* was work_to_do */
int dummy2; /* was visited_space */
int dummy3; /* was phase */
Three named holes, kept so the struct offsets don't move in a patch release. That is what "we reverted a GC in a patch release" looks like at the byte level.
Confidence. High on everything in the table with a linked source: the dates, the
3.14.0–3.14.4 window, 3.14.5 on 2026-05-10, the double revert (3.13 pre-release and
3.14.5/3.15), and the 3.16-via-PEP intent are all directly quoted from python.org
properties. One correction to the version of this story I was given: I could not
find any HTTPX-specific report. The production reproductions I can verify are
urllib3/ssl (gh-142516) and Django migrate (Adam Johnson). If an HTTPX report
exists, it is not in the revert thread, the improving-incremental-gc thread, or
gh-142516.
10. Free-threaded GC: two stop-the-world pauses¶
Under the GIL, the collector gets stable refcounts for free — nothing else runs. Remove
the GIL and that guarantee is gone: gc_refs arithmetic is meaningless if another thread
is mutating references mid-scan. PEP 703's answer:
The current CPython cyclic garbage collector involves two cycle-detection passes during each garbage collection cycle. Consequently, this requires two stop-the-world pauses when running the garbage collector without the GIL. The first cycle-detection pass identifies cyclic trash. The second pass runs after finalizers to identify which objects still remain unreachable. Note that other threads are resumed before finalizers and
tp_clearfunctions are called to avoid introducing potential deadlocks that are not present in the current CPython behavior.
Map that onto §4.4: pause 1 wraps deduce_unreachable(), pause 2 wraps
handle_resurrected_objects()'s second deduce_unreachable(). Between and after them,
threads run — because a finalizer or tp_clear can execute arbitrary Python, and running
arbitrary Python with every other thread frozen is a deadlock generator (finalizer takes
lock L; another thread holds L and is suspended by the STW; done).
There are also free-threading-specific pieces in Python/gc_free_threading.c visible in
_gc_runtime_state:
#ifdef Py_GIL_DISABLED
int freeze_active; /* True if gc.freeze() has been used. */
Py_ssize_t last_mem; /* Memory usage of the process (RSS + swap) after last GC. */
Py_ssize_t deferred_count; /* accumulates when collection is deferred due to
the RSS increase condition not being met */
PyMutex mutex;
#endif
The free-threaded build triggers collections partly on measured RSS growth, not purely
on object counts. That is exactly the improvement Antoine Pitrou argued for on the
incremental-GC threads, already shipped on one build and not the other. Also note
ob_gc_bits, replacing the _gc_prev flag bits: _PyGC_BITS_TRACKED,
_PyGC_BITS_FINALIZED, _PyGC_BITS_UNREACHABLE, _PyGC_BITS_FROZEN, _PyGC_BITS_SHARED,
_PyGC_BITS_ALIVE, _PyGC_BITS_DEFERRED.
(measured) — gc.collect() wall time over a 2M-object live graph, with four spinning
Python threads running:
| Build | gc.collect() wall time, 5 runs (ms) |
|---|---|
| 3.14.6 GIL | 72.0, 60.9, 79.5, 72.3, 67.4 |
| 3.14.6t free-threaded | 22.0, 31.4, 31.3, 31.7, 31.1 |
Do not read this as "free-threaded GC is 2× faster." Read it as: on the GIL build the collecting thread is competing with four bytecode-executing threads for the one GIL, so its wall-clock time includes waiting; on the free-threaded build it stops them and runs alone. The GIL build's number is contention; the free-threaded number is the true stop-the-world pause — during which all four other threads made zero progress. Same work, different accounting. Measuring the application-visible pause on both builds (instrument the spinners, not the collector) is Lab 6.
11. GC as a cache-hostility problem¶
Cross-reference 01-memory-hierarchy-and-caches.md
§10.4 before reading this section, and then look at what subtract_refs actually does:
for every tracked object in the generation, dereference Py_TYPE(op)->tp_traverse, call
it, and have it dereference every PyObject* field to reach AS_GC(op) — which is 16
bytes before the object, i.e. a different cache line in the general case.
That is a pointer-chase over the entire live tracked heap, with essentially no spatial locality, executed at least twice per collection. The prefetcher cannot help; the access pattern is data-dependent. It is the single most cache-hostile loop the interpreter runs.
(measured) — same object count, same graph shape, only the order in which objects are linked differs (sequential allocation order vs. a shuffled permutation):
| Live tracked objects | gc.collect(), sequential links |
shuffled links | ratio |
|---|---|---|---|
| 10,000 | 0.60 ms | 0.62 ms | 1.03× |
| 100,000 | 3.24 ms | 3.37 ms | 1.04× |
| 1,000,000 | 28.32 ms | 94.01 ms | 3.32× |
| 4,000,000 | 115.11 ms | 420.46 ms | 3.65× |
Below ~100k objects the whole graph fits in cache and layout is free. Above ~1M it is a
3.65× difference in GC pause from nothing but memory layout. The collector's work is
identical — same objects, same edges, same tp_traverse calls. This is the clearest
demonstration in this folder that Python performance is a memory-layout problem wearing an
interpreter costume.
It also explains a class of production mystery: "our GC pauses got 3× worse and we didn't change anything." You changed allocation order. Loading data in a different sequence, adding a shuffle, switching from batch to streaming ingestion — any of these re-arranges the heap without changing a single object count.
The honest part: I could not measure the post-GC cold-cache penalty¶
The claim I wanted to prove is that a GC pause costs the pause plus a degraded period afterwards, because the collector evicted the application's working set. I tried twice:
- Attempt 1 — 3M-object cold heap for the collector to walk, a ~1 MB hot working set
for the "application" to chase.
gc.collect()pause: 72.9 ms. Steady-state loop: 3279 µs. First loop after the GC: 3304 µs — +1%, inside the noise. - Attempt 2 — 4M-object cold heap, hot set enlarged to ~14 MB (bigger than L2, sized to live in the SLC), randomized traversal order, 5 trials × 4 sweeps. Mean first post-GC sweep 2.62 ms vs 2.55 ms warm: +2.7%, with individual post-GC sweeps ranging 2.48–2.82 ms — i.e. some faster than the warm baseline.
Verdict: inconclusive, and I am not going to dress it up. The most likely explanation
is instrument failure rather than a false hypothesis: each iteration of a Python pointer
chase costs ~10 ns of interpreter overhead (LOAD_ATTR + specialization check + refcount
traffic), which is the same order as the L2/SLC miss I'm trying to detect. The signal is
inside the interpreter's own noise floor. Settling it needs PMU counters
(L1D_CACHE_REFILL, LLC_MISSES) attributed to a window immediately after
gc.callbacks fires 'stop' — and this machine has no perf(1). On Apple silicon the
path would be Instruments' CPU Counters template or kperf; I have not done it.
What I can say with the measurements in hand: the pause itself (73–420 ms) is so much
larger than any plausible cold-cache tail that the tail is not where you should spend
your attention. Fix the pause. See 31-measurement-methodology.md
for why "my experiment showed nothing" is a result and not a failure.
12. Practical tuning and leak hunting¶
12.1 gc.freeze() before fork() — the Instagram technique¶
This is the highest-leverage GC intervention that exists for pre-forking servers, and it is worth understanding why rather than cargo-culting the three-line recipe.
A forked child shares the parent's pages copy-on-write. Nothing is copied until something
writes. Then the child runs a garbage collection — and update_refs() writes gc_refs
into the _gc_prev field of every tracked object in the generation. Those writes are
16 bytes apart across the entire heap, so they dirty essentially every page holding a
tracked object. The child has now privately copied the parent's whole object graph without
allocating anything.
gc.freeze() moves every currently-tracked object into a permanent generation that is
never scanned:
BEFORE fork, no freeze BEFORE fork, gc.freeze()
┌────────────────────────┐ ┌────────────────────────┐
│ gen0 │ gen1 │ gen2 │ │ permanent generation │
│ ●●●●●●●●●●●●●●●●●●●● │ │ ●●●●●●●●●●●●●●●●●●●● │
└────────────────────────┘ └────────────────────────┘
│ fork() │ fork()
▼ ▼
child gc.collect(): child gc.collect():
update_refs() writes _gc_prev permanent gen is never visited
on every object → no writes → pages stay shared
→ COW faults on ~every page
(measured) — 1.5M __slots__ records (≈3.0M tracked objects), 4 children, each running
gc.collect() five times, reporting its own RSS growth:
mode=nofreeze parent RSS: 397.5 MiB frozen= 0 still-tracked= 3005339
per-child RSS growth from gc.collect(): [381.9, 381.9, 381.8, 381.9] MiB
total = 1527.5 MiB
mode=freeze parent RSS: 397.4 MiB frozen= 3005339 still-tracked= 0
per-child RSS growth from gc.collect(): [0.6, 0.7, 0.7, 0.7] MiB
total = 2.7 MiB
1,527 MiB of copy-on-write un-sharing, reduced to 2.7 MiB, by one function call. Each child privately copied 96% of the parent's 397 MiB heap purely to write GC bookkeeping into it. With 4 workers that is 1.5 GB of RAM your container is paying for and your dashboard attributes to "the app".
The full recipe, from the gc docs:
gc.disable() # early in the parent: avoid creating freed "holes" in pages
...load everything...
gc.freeze() # immediately before fork()
os.fork() # or: let gunicorn/uvicorn --preload do it
gc.enable() # early in each child
gc.unfreeze() puts them back in the oldest generation; gc.get_freeze_count() tells you
it worked. Frozen objects are still freed by refcounting — freezing only removes them from
cycle detection.
12.2 gc.disable() — when it is actually safe¶
gc.disable() stops automatic collection. gc.collect() still works. It is safe when:
- The process is short-lived and its peak RSS fits comfortably (build scripts, CLI tools, Lambda-style handlers). mypy does this.
- You genuinely create no cycles — rare, and easy to be wrong about: any exception traceback holds a frame that holds the exception, generators reference their frames, and every heap type is a cycle with its module.
- You control collection explicitly at a safe point — e.g. between requests, or Adam
Johnson's Django workaround of
gc.collect()after each migration.
It is not safe as a general latency fix on a long-running service: you are trading a bounded pause for unbounded memory. The measured consequence, if you're wrong, is an OOM kill, which is a much worse p100 than a 400 ms pause.
Tuning without disabling: gc.set_threshold(20000, 10, 10) is the conservative knob (~10×
less frequent gen-0 work). (measured) — there is no PYTHON_GC_THRESHOLD
environment variable and no -X gc_threshold on 3.14.6; both were proposed on the 2024
thread and I could not find them in this build. gc.set_threshold(0, ...) disables gen-0
collection while leaving gc.isenabled() True, which is a good way to confuse your
future self.
12.3 Leak hunting¶
(measured), on a three-object cycle:
gc: collecting generation 2...
gc: objects in each generation: 11 0 5293
gc: objects in permanent generation: 0
gc: collectable <N 0x105cb0590>
gc: collectable <N 0x105c90590>
gc: collectable <N 0x105c90690>
gc: done, 3 unreachable, 0 uncollectable, 0.0003s elapsed
The flags, in the order you'll want them:
| Flag | Use |
|---|---|
DEBUG_STATS |
per-collection generation sizes and elapsed time — the cheapest GC observability there is |
DEBUG_COLLECTABLE |
print each cyclic object found |
DEBUG_UNCOLLECTABLE |
print objects that went to gc.garbage (→ a C extension with tp_del) |
DEBUG_SAVEALL |
put everything unreachable into gc.garbage instead of freeing it, so you can inspect it |
DEBUG_LEAK |
COLLECTABLE \| UNCOLLECTABLE \| SAVEALL |
The workflow that actually finds things:
gc.set_debug(gc.DEBUG_SAVEALL)
gc.collect()
for obj in gc.garbage:
print(type(obj), [type(r).__name__ for r in gc.get_referrers(obj)])
(measured) — for a self-referential instance this reports referrers
['dict', 'list', 'Leaky']: its own __dict__, the gc.garbage list itself, and the
instance. gc.get_referrers includes the frame you called it from and the container you
put results in — always subtract the observer. It is also slow (it traverses everything)
and returns containers, not attribute names; for anything beyond a handful of objects
use objgraph or memray instead
(32-profiling.md).
For continuous observability, gc.callbacks is better than polling (measured — a
two-object cycle, 'start'→'stop' delta of 265 µs):
gc.callbacks.append(lambda phase, info: metrics.emit(phase, info))
# ('start', {'generation': 2, 'collected': 0, 'uncollectable': 0})
# ('stop', {'generation': 2, 'collected': 2, 'uncollectable': 0})
Emit a histogram of stop−start per generation. A p99 GC pause metric costs you almost nothing and is the difference between diagnosing §11 in an hour and in a quarter.
12.4 The decision table¶
| Symptom | First hypothesis | Instrument |
|---|---|---|
RSS climbs forever, gc.collect() fixes it |
cycles + a threshold too high, or a disabled GC | gc.get_count(), DEBUG_STATS |
RSS climbs forever, gc.collect() does not fix it |
not a GC problem: unbounded cache, fragmentation, or a C-extension leak | memray, 16 §5 |
| Periodic multi-hundred-ms latency spikes | full collections over a large live heap | gc.callbacks histogram, then §11/§12.1 |
| Worker RSS = N × parent RSS after fork | COW un-sharing from update_refs |
§12.1, gc.freeze() |
gc.garbage non-empty |
a C extension with a legacy tp_del |
DEBUG_UNCOLLECTABLE |
__del__ not running |
a cycle, or resurrection, or interpreter shutdown | §7; switch to weakref.finalize |
13. Lab exercises¶
Reading this leaves you at rung 3 of the ladder in README §14 —
fluent, and one "why?" from collapse. These move you to rung 4. All use
~/.local/bin/python3.14 and python3.14t.
1 — Prove refcounting cannot free a cycle. (mandatory) Build a two-object cycle,
hold weakrefs, gc.disable(), del the names, show both objects alive. Then
gc.collect() and show them gone. Repeat with an acyclic pair and show refcounting
handles it with no collector involvement. Proves the two-mechanism split in §1 — and it
is the answer to the most common interview question on this topic.
2 — Walk §4 on your own graph. Build the A/B/C/D graph, print sys.getrefcount for
each, hand-compute gc_refs after update_refs and after subtract_refs, predict what
gc.collect() returns, then run it. Now change B to be reachable from a second external
name and re-predict. Proves you can run the algorithm, not just describe it. This is the
rung-4/rung-5 boundary for this doc.
3 — gc.freeze() before fork. (mandatory) Load ≥1M tracked objects, fork 4
children, have each child gc.collect() and report its own RSS delta through a pipe. Run
with and without gc.freeze(). Predict the delta before you run it. Then try
gc.freeze() without gc.disable() during the load and explain why the docs recommend
both. Proves §12.1 and is directly applicable to any gunicorn/uvicorn deployment.
4 — Make GC pauses 3× worse with no code change. Reproduce §11: same object count,
same edge count, sequential vs. shuffled link order. Find the object count at which the
curves separate on your machine and relate it to your L2/SLC size from
01-memory-hierarchy-and-caches.md. Proves that GC
cost is a memory-layout property.
5 — Break tp_traverse deliberately. Write a small C extension (or use Cython with
cdef class) with a Py_TPFLAGS_HAVE_GC type. Version A omits one field from
tp_traverse; version B visits a borrowed reference it doesn't own. Build both against a
--with-pydebug interpreter. Version A should leak; version B should trip
"refcount is too small". Proves §6, and it is the single best preparation for reviewing
extension code.
6 — Measure the real free-threaded pause. Take §10's benchmark but instrument the
spinner threads: have each record a timestamp every N iterations and report its maximum
inter-timestamp gap. Run on python3.14 and python3.14t. The GIL build should show
gaps from GIL contention; the free-threaded build should show a clean STW plateau. Proves
you understand why §10's table does not mean what it looks like it means.
7 — Resurrect an object and count the collections. Reproduce §7: a cycle whose
__del__ stashes self. Show gc.collect() returns 0, show gc.is_finalized is True,
show the cycle-mate survived too, then drop the reference and show it is freed with no
second __del__. Rewrite the whole thing with weakref.finalize and show it cannot
happen. Proves §7's four rules.
8 — Reproduce the incremental-GC failure shape. You cannot run 3.14.0–3.14.4 here, so
do it structurally: on 3.14.6, write a loop that creates one cycle per iteration and
trashes one per iteration after warmup. Instrument with gc.callbacks and count trash
cycles awaiting collection. Now gc.set_threshold(200000, 10, 10) to simulate a
collector that defers work, and plot RSS. Proves §9's lesson — "fewer objects scanned"
≠ "less memory" — with your own numbers rather than Tim Peters'.
14. Question bank¶
Staff-level. Section references are where to reread if your model can't produce the answer.
- CPython has reference counting. Why does it also need a cycle detector, and why is that detector's cost proportional to your live heap rather than your garbage? (§1)
- Where is
gc_refsstored, and what happens to_gc_prev's normal job during a collection? (§4.1) - Walk
update_refs→subtract_refs→move_unreachableon a 4-object graph where one member of a cycle is externally referenced. Which objects havegc_refs == 0after step 2, and why is that not the answer? (§4.3) - Why does
move_unreachablemove the unreachable objects, when most objects are reachable? (§4.3) - Two Python processes disagree on
gc.is_tracked((1, 2)). Give two distinct mechanisms that could explain it. (§3) gc.get_threshold()returns(2000, 10, 10). What does each number count, when did the first one change from 700, and why? (§5)- You allocate 10 million long-lived tracked objects. Why doesn't the collector go quadratic, and what is the exact hardwired constant that prevents it? (§5)
- Your C extension's type leaks under some workloads and segfaults under others. Give the two
tp_traversebugs that produce each, and which build catches them. (§6) - Before Python 3.4, a cycle containing an object with
__del__leaked forever. What exactly did PEP 442 change, and where is the "already finalized" state stored? (§7) gc.collect()returns 0 on a cycle you know is unreachable, and__del__definitely ran. Explain. (§7)- Your
WeakValueDictionarycallback that closes file descriptors fires for most entries and silently skips some. What shape of object graph causes that? (§8) - The incremental GC shipped in 3.14.0 and was removed in 3.14.5. State the exact failure mechanism, not just "memory grew." (§9)
- Free-threaded builds have two stop-the-world pauses per collection. Why two, and why are threads resumed before finalizers run? (§10)
- Your GC pauses tripled after a release that changed only data-loading order. Explain, and name the measurement that would confirm it. (§11)
- A pre-forking server with 8 workers uses 8× the parent's RSS within minutes. Diagnose it, fix it, and predict the size of the fix before you measure. (§12.1)
- When is
gc.disable()safe in production, and what is the failure mode when your safety argument is wrong? (§12.2)
15. Sources¶
Primary — read these, not this document
- InternalDocs/garbage_collector.md — the official design doc, ~37 KB, in the tree. Verdict: the single best source on this topic; read it end to end before anything else. Note it moved here from Doc/ and from the devguide (devguide.python.org/internals/garbage-collector/ is now a 404 — verified Aug 2026).
- Python/gc.c — the collector for GIL builds. Verdict: essential and surprisingly readable. Start at gc_collect_main() ("This is the main function. Read this to understand how the collection process works"), then deduce_unreachable(), then move_unreachable(). The comments are load-bearing.
- Python/gc_free_threading.c — the free-threaded collector. Verdict: read only after gc.c; it is a separate implementation, not a variant.
- Include/internal/pycore_gc.h and pycore_interp_structs.h — PyGC_Head, the _gc_prev flag bits, ob_gc_bits, _gc_runtime_state, and the threshold initializers. Verdict: the authority for every constant in §3–§5.
- gc — Garbage Collector interface — Verdict: authoritative on API, but the gc.is_tracked({}) example is stale as of 3.14 (§3). Trust your interpreter over the docs.
- PEP 442 — Safe object finalization — Verdict: short, and it is the whole of §7. Read the "C-level changes" section.
- PEP 703 §Garbage Collection — Verdict: the two-STW-pause design and the gc_refs/deferred-refcounting interaction, straight from Sam Gross. Read alongside 24-the-gil.md §8.5.
- PEP 683 — Immortal Objects — why update_refs untracks immortals outright.
The incremental GC saga (§9) - Reverting the incremental GC in Python 3.14 and 3.15 — Hugo van Kemenade, 16 Apr 2026, 20 posts. Verdict: the decision, the rationale, and the 3.16 plan. Primary source; read all of it. - Improving incremental gc — Tim Peters, 23 Apr 2026. Verdict: the best technical post-mortem. The toy reproduction and the "the math can go negative(!)" observation are here. - Incremental GC and pushing back the 3.13.0 release — Thomas Wouters, 28 Sep 2024. Verdict: the first revert, plus Neil Schemenauer's threshold table and Itamar Turner-Trauring's "a dozen services OOMed" anecdote. This is where the 2000 came from. - gh-142516 — memory leak in ssl library: Python 3.14 GC issue — Verdict: the production report the revert announcement links to (MSAL → requests → urllib3 → ssl). Opened 2025-12-10. - Django: fixing a memory "leak" from Python 3.14's incremental GC — Adam Johnson. Verdict: the best real-world write-up, with a working mitigation. - Python 3.14.5 rolls back the incremental garbage collector — Tim Hopper. Verdict: accurate secondary summary; good for orientation, then go to the discuss threads. - What's New in Python 3.14 — Garbage collection — Verdict: the canonical statement, and it documents both states (3.14.0–3.14.4 and 3.14.5+). Quote this one in a design doc.
Background
- The Garbage Collection Handbook, 2e (Jones, Hosking & Moss) — ch. 5 (reference counting) and ch. 9 (generational). Verdict: reference, not a read-through; but read ch. 5's treatment of cycles once and §1 of this doc becomes obvious.
- gc_weakref.txt — the design note handle_weakrefs() points at for §8.
Sibling docs
- 15-refcounting-and-ownership.md — mechanism one.
- 16-object-memory-layout.md §1–2 — PyGC_Head, and why it vanishes on free-threaded builds. (Its §1 table says (1, 2) is untracked; §3 here refines that — it depends on the construction path and whether a collection has run.)
- 24-the-gil.md §8.5 — the stop-the-world design, from the concurrency side.
- 01-memory-hierarchy-and-caches.md §10.4 — the pointer-chasing cost model §11 measures.
- 35-memory-optimization.md — applying §12 to a real service.
Next: 23-tracing-and-runtime-hooks.md — gc.callbacks
generalized: PEP 669 monitoring, audit hooks, and watching a running interpreter without
paying for it.