← back to journal

A full Python GC pass costs 46 ns per object, and gc.freeze() makes it free

2026-10-14 · Kit Wickham

Python's cyclic garbage collector has to look at every tracked object when it does a full pass, so the pause grows with the size of your heap. I wanted the actual slope. On CPython 3.14.8 it was 46 ns per object, about 93 ms for a million small dicts-with-lists and 371 ms for four million. After gc.freeze() the same pass took under a hundredth of a millisecond.

The test

I built a list of dicts, each holding a small list, so every entry is two tracked containers, and timed gc.collect() at 250,000, 1,000,000 and 4,000,000 entries. Median of five calls each. Then I called gc.freeze() and timed one more.

entriesfull collectper tracked objectafter freeze
250,00023.6 ms47 ns~0 ms
1,000,00092.9 ms46 ns~0 ms
4,000,000370.9 ms46 ns~0 ms

It is a straight line. Frozen objects move to a permanent generation that collections skip, so a program that builds its big read-mostly data up front (a loaded model, a routing table, a parsed config) can freeze it once and stop paying for it.

The automatic pass rarely fires

The other half of the question is how often you get hit without asking. I held a live heap of 100,000, 1,000,000 and 4,000,000 entries, then allocated two million self-referencing lists that die straight away and logged every collection through gc.callbacks. Each run had about 880 young-generation passes (worst 0.6 to 1.6 ms) and 80 middle-generation ones (worst about 1 ms). The full pass ran once at the smallest heap, 13 ms, and not at all at the two larger ones.

That is the 25% rule: an automatic full pass only starts when the number of objects that survived the last middle pass exceeds a quarter of the long-lived total. Churn that dies young never gets there. A workload that keeps adding objects it never frees does, and then each full pass costs the line above.

So what

If a service shows latency spikes of tens to hundreds of milliseconds and holds millions of containers, a full collection is a plausible cause, and gc.callbacks will tell you in a few lines. If the big data is loaded once, gc.freeze() after loading removes it from every later pass. Objects that are not GC-tracked, such as plain ints, strings, and tuples of those, cost nothing here.

Method: one machine, lab container, CPython 3.14.8, a single process per run, other jobs running alongside so treat the timings as ±10%. The objects were small and uniform; real heaps with deep reference graphs may cost more per object.