Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·25 MIN

Garbage collection: why an object is alive when nothing refers to it

In the standard CPython build an object dies the moment its last reference disappears — no collector is needed for that. The collector is needed for cycles, and only for them. And an object that “will not die” may be held not by a garbage cycle but by a reference from a live object that you cannot see in the code: an exception's traceback, a cache key, a method in a subscriber list.

Full technical treatment

TL;DR

In the standard CPython build an object dies when its last reference disappears — at once, on that very line. That is the job of reference counting, not of the garbage collector. del does not destroy an object; it removes a name and decrements the count by one. The collector (gc) is needed for exactly one case the count cannot handle: a cycle. Two nodes refer to each other, the names are gone, yet each count stays at one — and the objects live until gc.collect(), which finds a group with no outside references and frees it.

Beyond that is what separates knowing from having read. When memory “is not released”, look not only for a garbage cycle but also for a reference from a live object that you cannot see in the code. An exception stored in an attribute holds its traceback, the traceback holds the failed function's frame, and the frame holds all its local variables. lru_cache on a method holds self in its key. A method put in a subscriber list holds its object in __self__. All of it is reachable from a live object, and the collector will not touch it — gc.collect() changed nothing in the run. But one change breaks the path, and the object dies on that same line. And a full collection is paid for not by garbage but by live objects: a million live lists — 39.88 ms per gc.collect() without a single garbage object (3.13.7).

Since 3.13 CPython has two builds, and everything above is about the standard one, with the GIL. In the build without the GIL (3.13t, 3.14t) an instance created and released by one thread also dies on the del line. But a module-level function waits for the collector after del, an object whose last reference was released by another thread outlives that line, and gc.freeze() does not take everything off the collection.

Where to start
Before this lesson it is enough to understand
  • that a variable is a name bound to an object;
  • that an object can be put in a list, in a dict, in another object's attribute;
  • how try / except works and what an instance method is.
You do not need to know in advance
  • reference counting, gc.collect(), unreachable cycles,weakref;
  • the traceback and frame in an exception, lru_cache on a method, WeakMethod, PEP 442, gc.freeze().

Base: an object dies when its last reference disappears

Every object in CPython has a counter: how many references to it exist right now. A name is a reference. A list element is a reference. Another object's attribute is a reference. When a reference appears the count goes up; when it disappears the count goes down. Once it reaches zero, the object is freed at once.

That is how the standard CPython build works — the one installed by default. Since 3.13 there is a second one, without the GIL; there the rule has exceptions, covered at the end of the lesson. They do not affect the Junior-level answer: for an object that one thread creates and releases, the rule holds in both builds.

Hence the Junior-level answer to “how is memory freed in Python”: the main mechanism is reference counting: an object is freed as soon as no references to it remain. The garbage collector supplements it and is needed for cycles.

And a consequence people trip over: del x does not delete the object. It deletes the name x and decrements the count by one. If that was the last reference, the object dies. If not, it lives on.

You can check whether an object is alive without holding it — with a weak reference from the weakref module. It does not increase the count, and after the object's death it returns None. Every run in this lesson uses that trick.

Mechanism 1: a cycle is the only thing the count cannot handle

measured observationbench/gc/alive.py, sections 1–2. The collector is disabled during the experiment so it cannot fire between lines on its own; identical on 3.11–3.14.

Without a cycle, everything happens on the del line:

1) no cycle
   before del: alive
   right after del: dead — freed by reference counting, no collector needed

Now two nodes that refer to each other:

PYTHON
a, b = Node("a"), Node("b")
a.other, b.other = b, a
del a, b
2) a two-node cycle
   sys.getrefcount(a) before del: 3
   after del a, b: alive and alive
   gc.collect() returned 2; now: dead and dead

sys.getrefcount shows 3: the name a, the reference from b.other and the function's own argument — the call itself adds the third unit. After del there are no names, but each node still has one reference — from its neighbour. The count will never drop to zero: the program cannot reach the nodes, and they hold each other.

That kind of group is what the cycle collector looks for. The documentation describes its role in one phrase: the collector supplements the reference counting already used in Python.

It walks the tracked objects (containers — lists, dicts, class instances; numbers and strings it does not track, since they never refer to other objects) and finds groups with no references from outside. gc.collect() returns the number of unreachable objects found — 2 here. It also runs on its own, by thresholds on the number of objects created; how those thresholds and generations work is covered in the article on memory management.

Mechanism 2: the object is alive not because of a cycle but because of a reference you cannot see

measured observationbench/gc/alive.py, sections 3–5; identical on 3.11–3.14.

The first thing that comes to mind for “why is memory not released” is “there is a cycle somewhere.” But an object can also be held by an ordinary reference from a live object — quite legitimately, it is just not in plain sight in the code. Here are three such references.

An exception's traceback holds the frame

An exception has a __traceback__ attribute. The traceback holds the frame of every function the exception passed through, and a frame holds all its local variables. The language reference says so directly, explaining why the name from except ... as e disappears after the block:

Exceptions are cleared because with the traceback attached to them, they form a reference cycle with the stack frame, keeping all locals in that frame alive until the next garbage collection occurs.

— Language reference, the try statement

The run checks three cases: the exception is stored nowhere; it is stored in a local variable of the same function; it is stored in an attribute of a live object.

3) an exception and the frame of the failed function
   the exception is stored nowhere: payload after the function returns — dead
   stored in the local err: payload after return — alive
     cycle: frame -> err -> exception -> __traceback__ -> frame
     after gc.collect(): dead
   stored in svc.last_error: payload after return — alive
     path from the live svc: svc -> exception -> __traceback__ -> frame -> payload
     gc.collect() with svc alive: alive
     svc.last_error = None: dead
   the name e after the except block: NameError — the interpreter itself deleted it

The second and third cases look the same — “payload is alive” — but they are built differently, and the cure depends on it. The local err = e brings back exactly the cycle from the reference: after the function returns nothing can reach it, and the object waits for the collector. With the attribute svc.last_error everything is reachable from the live service — and the collector will not touch it: gc.collect() with svc alive did not free the object. But there is no need to wait for it either: svc.last_error = None, and the object died on that very line. Store the error's text in the attribute, not the exception itself.

lru_cache on a method holds self

4) functools.lru_cache on a method
   del rep: alive — self sits in the cache key
   gc.collect(): alive
   Report.total.cache_clear(): dead

The cache lives on the function, that is on the class, and self is part of the key. The Python documentation describes this in its FAQ: The disadvantage is that instances are kept alive until they age out of the cache or until the cache is cleared.

With maxsize=None there is no eviction at all, and every instance whose method was called even once lives until cache_clear() or until the process ends.

A method in a subscriber list holds the object

5) a bound method in a subscriber list
   callbacks.append(w.on_event); del w: alive — the method holds __self__
   gc.collect(): alive
   callbacks.clear(): dead
   the same via weakref.WeakMethod; del w: dead
   weak_callbacks[0]() -> None

w.on_event is a bound method, an object whose __self__ holds w. Subscribing a method to an event means holding a strong reference to the whole object. weakref.WeakMethod refers to it weakly: the object dies, the subscription returns None, and it can be dropped at the next dispatch.

Mechanism 3: since 3.4, __del__ in a cycle does not block collection

Before Python 3.4, the collector would not touch a cycle containing an object with __del__: it was unclear in what order to call finalizers when objects refer to each other. Such objects piled up in gc.garbage. PEP 442 removed that: to be able to define and run finalizers for any object, regardless of their position in the object graph.

6) __del__ on both nodes of a cycle
   gc.collect() returned 2; finalizers ran: ['a', 'b']
   gc.garbage: []

Both finalizers were called, and gc.garbage is empty. But the PEP explicitly leaves the order in which they are called undefined: For CI objects, the order in which finalizers are called (step 2 above) is undefined. Here it came out a, then b — do not rely on that. And in the __del__ of an object in a cycle, its neighbour may already be finalized.

Deeper: what a full collection pays for

measured observationbench/gc/pause.py, CPython 3.13.7, best of seven calls. The times were taken on one machine; what matters is that the time grows with the number of live objects, not garbage ones.

A full collection — gc.collect() with no arguments — walks all tracked objects, live ones included: to know that a group is unreachable, you have to know what everything else refers to. So its time grows with the number of live containers, even when there is no garbage at all:

PY 3.13.7 | best of 7 calls to gc.collect()

  live lists      gc.collect()   per object
         10 000        0.75 ms     45.8 ns
        100 000        3.57 ms     32.8 ns
      1 000 000       39.88 ms     39.6 ns
     empty heap        0.29 ms

A few tens of nanoseconds per live object — from 32.8 to 45.8 ns in this table: a million live lists is almost a 40 ms pause per full collection. The collector itself runs a full collection rarely — the thresholds are designed to keep most of the work in the young generation — but a process holding a large long-lived cache in memory pays for it on every such collection.

Taking live objects out of the walk is what gc.freeze(), added in 3.7, can do: it moves everything tracked into a permanent generation, and later collections do not walk it. But it was designed not as a general cure for pauses but for one case — more on it below.

  the same million live lists
    before gc.freeze():  38545.7 µs
    after gc.freeze():       0.3 µs, 1 005 045 objects in the permanent generation

The documentation suggests it for processes that fork without exec: the collector in the child does not touch frozen objects, and fewer memory pages are copied on write. As a general trick against pauses it works with two caveats. Garbage cycles among the frozen objects are no longer collected until gc.unfreeze() is called — that memory is given away for good. And in the build without the GIL freezing does not take everything off the collection; the next section shows it.

Deeper: the build without the GIL — the same rule, with exceptions

measured observationbench/gc/freethreaded.py on 3.11–3.14 with the GIL and on 3.13.7 and 3.14.7 without it; below is the 3.14t output. Experiments 1–3 are observations without timing; in experiment 4 the times come from one machine, and what matters is the before-and-after comparison within the run.

Since 3.13 CPython can also be built without the GIL (PEP 703). Reference counting works differently there: an object has an owning thread, and operations from that thread take the fast path while those from another thread take the shared one. How that affects speed is covered in the article on the GIL, and how it affects object size in the article on memory management. Here the question is what it means for “when does the object die”.

The main rule of the lesson holds: an instance created and released by one thread dies on the del line without the GIL too. All six experiments in bench/gc/alive.py on 3.13t and 3.14t give the same “alive or dead” answers as with the GIL. But three things diverge:

2) a function defined at module level
   right after del: alive
   after gc.collect(): dead
   for comparison, a nested function right after del: dead
3) the last reference is deleted by a thread other than the one that created the object
   alive in that thread right after del: 19 of 20
   alive in the main thread after join(): 0 of 20
4) gc.freeze() and a million live lists, best of 7 gc.collect() calls
   before gc.freeze():  37272.2 µs
   after gc.freeze():   23155.0 µs, 1 007 024 objects in the permanent generation

A module-level function waits for the collector. With the GIL it dies on the del line, without it only on gc.collect(); a nested function dies at once in both builds. The same effect shows up in bench/gc/alive.py: in the sixth experiment gc.collect() without the GIL returned 3, not 2, and the third object was the class body function — with the GIL the count freed it right after the class was created.

The last reference from another thread. The main thread created the object, and another thread took and deleted the last reference. Right after del in that thread the object was alive in 19 attempts out of 20 on 3.14t and 20 out of 20 on 3.13t; in the main thread after join() it was dead in all of them. With the GIL it is dead at once, 0 out of 20 on all four versions. The number of attempts is printed for a reason: the result depends on thread scheduling.

gc.freeze() does not take everything off. With the GIL, after freezing, a collection over a million live lists takes a fraction of a microsecond — 0.2–0.3 µs in this same run. Without the GIL it is 23.2 ms out of 37.3 on 3.14t and 23.4 out of 63.5 on 3.13t: a noticeable part of the collector's work stays.

Hence the precise wording for an interview: in the standard CPython build an object without a cycle dies at once when its count reaches zero; in the build without the GIL that is true for a single thread's object, but not for every object.

How to answer in an interview

Short answer: in CPython memory is freed first of all by reference counting: an object dies as soon as its last reference disappears, at once. del deletes a name, not an object. The garbage collector supplements the count and is needed for cycles: objects that refer to each other would never be freed by the count, and gc finds such groups with no outside references.

That is enough to answer correctly. Beyond it is what you add if the interviewer digs.

If the interviewer digs deeper

If leaks come up, a good answer starts not with cycles but with the fact that an object can be held by an ordinary reference from a live object that you cannot see: an exception in an attribute holds its traceback, the frame and all its local variables; lru_cache on a method holds self in its key; a method in a subscriber list holds its object in __self__. All of it is reachable, and the collector will not touch it. One change cures it: store the error's text, clear the cache, subscribe through weakref.WeakMethod.

A detail worth adding is why the name from except ... as e disappears after the block: otherwise the frame would hold the exception, the exception would hold the frame through its traceback, and there would be a cycle with all the local variables. That is written in the language reference. And about the cost of collection: it grows with the number of live objects — a million live lists, 39.88 ms per gc.collect() with no garbage (3.13.7) — and gc.freeze() takes them out of the walk, in the standard build.

If the build without the GIL comes up, it is worth saying that “dies on the same line” is not always true there: a module-level function waits for the collector, and an object whose last reference was released by another thread outlives del in that thread.

Next they ask

Next they ask

Can the collector be turned off?

Short answer

Yes — gc.disable(), and the documentation allows it if the program creates no cycles. Reference counting keeps working, and objects without cycles are freed as before. But any cycle — including the “frame — exception” cycle — will live until an explicit gc.collect() or until the process ends. gc.freeze() is gentler: it takes what already exists out of the walk, while new cycles are still collected.

Next they ask

How do you find what is holding an object?

Short answer

gc.get_referrers(obj) returns the objects that refer to it directly — the documentation asks that it be used for debugging only. Following a chain of such calls leads to the holder: an attribute, a cache, a subscriber list. To check whether the object died after the fix, use a weak reference, as in the lesson's runs.

Next they ask

Is __del__ in a cycle a leak?

Short answer

Since 3.4, no: PEP 442 allowed the collector to finalize such cycles, and gc.garbage is empty. But the order of finalizer calls in a cycle is undefined, and in __del__ a neighbour in the cycle may already be finalized.

Common misconceptions

Claim

del x deletes the object

Actually

It deletes the name x and decrements the reference count by one. The object dies only if that was the last reference. In a two-node cycle, after del a, b both are alive and die only on gc.collect().

Claim

Memory in Python is freed by the garbage collector

Actually

First of all by reference counting, and at once: an object without a cycle is freed on the same line where its last reference disappeared, even with the collector off. The collector supplements the count and is needed only for cycles.

Claim

If an object is not freed, there is a cycle somewhere

Actually

It can also be an ordinary reference from a live object that you cannot see in the code: an exception in an attribute holds the frame and all its local variables, lru_cache on a method holds self, a bound method in a subscriber list holds its object. All of it is reachable: gc.collect() in the run did not free such an object, and one change frees it on that same line.

Claim

Objects with __del__ in a cycle are never collected

Actually

That was before 3.4. Since PEP 442 such cycles are collected: in the run gc.collect() returned 2, both finalizers ran, and gc.garbage is empty. Only the order in which the finalizers are called is undefined.

Claim

An object with no references dies at once in any CPython build

Actually

That is the standard build, with the GIL. In the build without the GIL a module-level function is alive after del and dies only on gc.collect(), and an object whose last reference was deleted by a thread other than the one that created it is alive in that thread right after del — 19 attempts out of 20 on 3.14t. An instance of a single thread dies at once in both builds.

Claim

Garbage collection costs more the more garbage there is

Actually

A full collection walks all tracked objects, live ones too. Without a single garbage object: 0.75 ms with ten thousand live lists and 39.88 ms with a million (3.13.7). After gc.freeze() the same million are not walked.

Version history

VersionChangeWhat it means for code
3.4PEP 442: cycles with __del__ are collected and no longer end up in gc.garbage. Before that the advice was to keep objects with __del__ out of cycles.
3.7gc.freeze(), gc.unfreeze() and gc.get_freeze_count() appear — a way to take long-lived objects out of the collector's walk, above all before a fork.
3.13The young-generation threshold defaults to 2000 instead of 700. Verified by the run: gc.get_threshold() gives (700, 10, 10) on 3.11.15 and 3.12.3 and (2000, 10, 10) on 3.13.7 and 3.14.7. The change of threshold is visible in the source at the v3.13.0 tag too; what exactly these numbers count is in the memory management article. The build without the GIL also appears (PEP 703; sys.version says experimental free-threading build): an instance of one thread dies on the del line there too, while a module-level function waits for the collector (bench/gc/freethreaded.py).
3.14In the standard build: in 3.14.0 the collector became incremental, and in 3.14.5 that was rolled back — details and the source are in the memory management article. It does not affect the behaviour covered in this lesson: all six experiments in bench/gc/alive.py give the same output on 3.14.7 as on 3.11–3.13. The build without the GIL (sys.version now says free-threading build, without experimental) is built with a different collector — the source path in the library is Python/gc_free_threading.c versus Python/gc.c — and behaves differently: gc.freeze() brings a full collection over a million live lists down from 37.3 to 23.2 ms, not to a fraction of a microsecond.

Practice

Two exercises. Answer first, then check against the real output: in both, the correct answer comes from a run of the script rather than being assigned.

Practice · predict the output

Two nodes refer to each other, and the collector is disabled. What does this code print?
class Node:
  pass


gc.disable()
a = Node()
b = Node()
a.other = b
b.other = a
r = weakref.ref(a)
del a, b
print(r() is None)
gc.collect()
print(r() is None)

Practice · estimate

There is no garbage at all. There were 100 thousand live lists, now there are a million. How many times longer does a full gc.collect() take?
times

Check yourself

Question 1 of 4

An object with no cycles, one reference to it — the name x. The collector is off via gc.disable(). What happens on del x?

How it was measured

The numbers in this lesson come from these scripts. Each one opens right from here — together with the record of its run: what it ran on, what came out, and with what spread.

Sources & further reading

6 SOURCES

  1. Language reference — the try statement, the except clauseOfficial documentation. The most direct explanation of how a traceback holds memory: “Exceptions are cleared because with the traceback attached to them, they form a reference cycle with the stack frame, keeping all locals in that frame alive until the next garbage collection occurs.” That is also why the name after except as disappears.https://docs.python.org/3.14/reference/compound_stmts.html#except-clause
  2. gc — Garbage Collector interfaceOfficial documentation. The collector's role: “the collector supplements the reference counting already used in Python.” gc.collect: “With no arguments, run a full collection.” gc.freeze, added in 3.7: “Freeze all the objects tracked by the garbage collector; move them to a permanent generation and ignore them in all the future collections.”https://docs.python.org/3.14/library/gc.html
  3. PEP 442 — Safe object finalizationPEP. Antoine Pitrou, Final, Python 3.4. Before it, the collector left an object with __del__ in a cycle alone. The PEP's goal: “to be able to define and run finalizers for any object, regardless of their position in the object graph.” And the limit of the guarantee: “For CI objects, the order in which finalizers are called (step 2 above) is undefined.”https://peps.python.org/pep-0442/
  4. Programming FAQ — How do I cache method calls?Official documentation. Why lru_cache on a method holds instances: “It creates a reference to the instance unless special efforts are made to pass in weak references” and “The disadvantage is that instances are kept alive until they age out of the cache or until the cache is cleared.”https://docs.python.org/3/faq/programming.html#faq-cache-method-calls
  5. PEP 703 — Making the Global Interpreter Lock Optional in CPythonPEP. Sam Gross, Final, Python 3.13. The CPython build without the GIL and its reference counting: an object has an owning thread, and operations from that thread take the fast path while those from another thread take the shared one; the basis is the observation that “most objects are only accessed by a single thread, even in multi-threaded programs”. What this means for an object's lifetime the lesson checks by running bench/gc/freethreaded.py on 3.13t and 3.14t, not by paraphrase.https://peps.python.org/pep-0703/
  6. weakref — Weak referencesOfficial documentation. The definition the liveness checks in this lesson's runs rest on: “A weak reference to an object is not enough to keep the object alive.” And WeakMethod: “Since a bound method is ephemeral, a standard weak reference cannot keep hold of it.”https://docs.python.org/3/library/weakref.html