Skip to content
← All articles

Reference counting, and the four ways it stops being deterministic

Refcounting is why __del__ appears to work. Cycles, exception tracebacks, the REPL's underscore and any global registry each break it — and on the free-threaded build prompt destruction is not even the design goal.

CPython’s primary memory management is reference counting: every object knows how many references point at it, and the moment that count hits zero the object is freed. Not “eventually”, not “at the next collection” — immediately, on the line that dropped the last reference.

That property is genuinely valuable. It is why a with block frees a large intermediate array on the closing line rather than at some later pause, and it is why CPython’s memory profile is flatter than a tracing-GC runtime’s. It is also the single most dangerous thing about the language for someone writing resource-handling code, because it makes a broken idiom look correct:

class Connection:
    def __init__(self, pool: Pool) -> None:
        self._pool = pool
        self._conn = pool.checkout()

    def __del__(self) -> None:
        self._pool.checkin(self._conn)     # works in dev. Leaks in prod.

In development this is flawless. Every test creates a Connection, drops it, and the finaliser runs on the spot. The pool balances. The code review passes. Then it goes to production and the pool drains over about four hours.

The four ways prompt destruction evaporates

1. Reference cycles

Refcounting cannot free a cycle. If parent.children holds child and child.parent holds parent, neither count ever reaches zero, and both objects live until the cycle collector runs — which is a separate, generational, non-deterministic mechanism (article 11.3). The moment your object graph is a tree with back-pointers, a doubly-linked list, an observer registration, or a closure that captured self, prompt destruction is gone.

You rarely put the cycle there on purpose. functools.partial(self.handler) stored on self is a cycle. A logger holding a formatter holding a reference back is a cycle. An exception stored as an attribute on the object that raised it is a cycle.

2. Exception tracebacks pin frames — and every local in them

This one costs the most memory in practice and is the least known:

try:
    process(giant_dataframe)
except ValueError as err:
    failures.append(err)         # <- you just retained the whole call stack

An exception object holds __traceback__, a traceback holds frames, and a frame holds every local variable in that frame. Storing an exception for later — in a list of failures, on a retry record, in a Result object, in a logging extra — retains the entire stack that raised it, including that dataframe, until the exception object itself is dropped.

Python 3 mitigates the narrowest form: at the end of an except ValueError as err: block, err is deleted automatically, precisely because the pre-3.0 behaviour leaked. But that only helps the name inside the block. If you copied it out, you own the graph.

The fix when you genuinely need to keep failure information is to keep a rendering, not the object: traceback.format_exc(), or the message and type, or a structured record. Keep the exception object itself only for the duration you need to re-raise it.

3. The REPL’s _, and every other implicit cache

In an interactive session, _ holds the last displayed result. So does Out[n] in IPython. sys.last_value holds the last uncaught exception, with its traceback and its frames. If you are measuring memory in a REPL, you have at least two extra strong references you did not write.

4. Anything that outlives the scope: registries, module globals, caches

@lru_cache is a dict that never forgets. A module-level HANDLERS: dict[str, Handler] = {} is a dict that never forgets. weakref exists because this category is so common; article 11.6 is entirely about it.

💡A service handles requests, and each handler does self._recent.append(response) on a list capped at 200 entries with self._recent = self._recent[-200:]. RSS still climbs. Where would you look first, given the four categories above? click to reveal

At what a response transitively holds. The cap bounds the number of retained objects, not their size, and the leak in this shape is almost always the object graph hanging off each one.

Concretely: if the response holds the request, and the request holds the parsed body, then 200 responses is 200 parsed bodies. If any of those 200 is an error response carrying an exception, it is also carrying that exception’s traceback, its frames, and every local in them — which is category 2, and can easily be an order of magnitude more than the response itself.

The diagnostic is not a debugger; it is tracemalloc (article 11.13) to find the allocating line, and then a walk of what a single retained object actually reaches (article 11.12). The fix is almost always to store a projection — an id, a status, a rendered message — rather than a live object.

Free-threading removes the guarantee entirely

On the free-threaded build, immediate deallocation is not the design. Deferred reference counting, biased reference counting and QSBR all exist to avoid touching an object’s header on the hot path, which means the moment at which memory is actually reclaimed is decoupled from the moment the last reference is dropped.

Code that depends on prompt destruction does not raise there. It just behaves differently — file descriptors return later, pool slots return later, and the failure mode is a resource exhaustion under load rather than an exception you can trace.

What to write instead

The rule is short: __del__ is for freeing memory you allocated yourself, not for releasing resources you borrowed. Anything with an owner — a connection, a file, a lock, a socket, a pool slot, a subprocess — gets an explicit lifetime, and Python spells explicit lifetime with.

class PooledResource:
    def __enter__(self) -> Self: ...
    def __exit__(self, exc_type, exc, tb) -> None: ...

Two details in that signature carry real weight.

__enter__ returning Self rather than the class name is what makes a subclass’s with block bind to the subclass type. Writing -> PooledResource silently widens every subclass.

__exit__ returning None rather than bool is a semantic choice the type checker will happily let you get wrong. __exit__ returning a truthy value suppresses the exception passing through the block. If you annotate -> bool and end the method with return True — which reads like “yes, I cleaned up” — you have written a with statement that swallows every exception raised inside it. mypy will not object; the annotation and the code agree. The only thing that catches it is knowing the protocol.

Annotate -> None and the checker starts helping you: a stray return True becomes an error.

💡Why must __exit__ return the resource to the pool even when the block raised, and why is finally: inside the block not an acceptable substitute? click to reveal

Because the exception is the case that matters. The happy path returns the resource by construction — every reviewer checks it. The path that leaks is the one where something threw halfway through, which is exactly the path under load, under partial outage, under the timeout you are already having a bad day about. A cleanup that only runs on success is a cleanup that only runs when you did not need it.

finally: inside the block is not wrong, it is unenforceable. It puts the correctness burden on every call site, forever, including the one someone adds next quarter under time pressure. with moves the burden into the resource class, where it is written once and reviewed once. That is the whole argument for context managers: not that they are shorter, but that they make the correct behaviour the default rather than a convention.