A full Python GC pass costs 46 ns per object, and gc.freeze() makes it free
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.
| entries | full collect | per tracked object | after freeze |
|---|---|---|---|
| 250,000 | 23.6 ms | 47 ns | ~0 ms |
| 1,000,000 | 92.9 ms | 46 ns | ~0 ms |
| 4,000,000 | 370.9 ms | 46 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.