Skip to content
← All articles

Weak references, and why @lru_cache on a method leaks

A registry that remembers is a registry that leaks. weakref.ref, WeakValueDictionary, WeakSet and finalize — plus the two things that surprise people: not every object supports weak references, and weakref.ref[T]() returns T | None, which --strict makes you handle.

The most common memory leak in production Python looks like a performance optimisation, and it passes code review because it is one:

class ReportBuilder:
    @lru_cache(maxsize=128)          # <- leaks every ReportBuilder, forever
    def render(self, template: str) -> str:
        ...

lru_cache is a dict that lives on the function object, which lives on the class, which lives for the lifetime of the process. Its keys include every argument — and for a method, the first argument is self. So every ReportBuilder instance that ever calls render is stored, strongly, in a class-level dict that nothing ever clears. Instances are never collected. Whatever they hold — a request, a connection, a dataframe — is never collected either.

The stdlib documentation says so directly, in the FAQ, and it is worth quoting because people are surprised it is a documented footgun rather than a subtle one: caching on a method keeps the instance alive.

functools.cached_property is the well-behaved cousin — the docs note it “does not create a reference to the instance”, because it stores the computed value in the instance’s own __dict__. It has a different unbounded behaviour (one cached value per instance, forever, for as long as the instance lives), but it does not extend the instance’s lifetime.

What a weak reference is

A weak reference points at an object without contributing to its reference count. When the last strong reference goes away, the object is destroyed and every weak reference to it starts returning None.

import weakref

class Resource:
    def __init__(self, name: str) -> None:
        self.name = name

obj = Resource("db")
ref = weakref.ref(obj)

ref()            # <Resource ...>  -- calling the ref dereferences it
del obj
ref()            # None

The four things worth knowing:

  • weakref.ref(obj) — the primitive. Call it to dereference.
  • WeakValueDictionary — a dict whose values are weak. Entries disappear when their value dies. This is the right shape for a cache or registry keyed by id.
  • WeakSet — a set of weakly-held members. The right shape for an observer list.
  • weakref.finalize(obj, callback, *args) — run a callback when obj is collected. Unlike __del__ it does not create a cycle, it is guaranteed to run at interpreter exit by default, and it does not live on the class.

There is a WeakKeyDictionary too, for “extra data attached to an object I do not own” — the entry dies when the key does.

Not every object can be weak-referenced

This is the part that bites in a typed codebase, because it interacts with the memory optimisation you were told to reach for:

from dataclasses import dataclass
import weakref

@dataclass(slots=True)
class Slotted:
    name: str

weakref.ref(Slotted("x"))     # TypeError: cannot create weak reference

Instances of a class with __slots__ have no __dict__ and no __weakref__ slot unless one is declared. @dataclass(slots=True) does not add it. The fix is @dataclass(slots=True, weakref_slot=True), or, for a hand-written class, including '__weakref__' in __slots__.

Also not weak-referenceable: int, str, tuple, list, dict and most other built-in types. A weak registry of strings does not work; a weak registry of your own objects does.

💡You add slots=True to a dataclass for memory reasons and a distant part of the codebase starts raising TypeError: cannot create weak reference. What does that tell you about the coupling between those two modules, and how would you express it so a checker catches it next time? click to reveal

It tells you that “can be weak-referenced” is part of the type’s public contract and was never written down. Some other module — a cache, an observer registry, a WeakValueDictionary of live sessions — depends on a capability of the class that no annotation mentions, so removing it is invisible at review time and appears as a runtime error somewhere unrelated.

The way to make it checkable is to make the requirement structural. Define a Protocol with __weakref__ as a member and annotate the registry’s parameter with it; a class without the slot then fails to satisfy the protocol at the call site rather than at the weakref.ref call. That is a small amount of ceremony for a capability that is otherwise entirely invisible.

The cheaper version, if a protocol feels heavy: a single unit test that constructs the class and takes a weak reference to it. It is one line, it names the requirement, and it fails in the module that owns the class rather than three layers away.

The typing consequence --strict forces on you

weakref.ref is generic. weakref.ref[Resource] called returns Resource | None:

ref: weakref.ref[Resource] = weakref.ref(obj)
ref().name          # error: Item "None" of "Resource | None" has no attribute "name"

Untyped code writes ref().name and gets an AttributeError on the day the object happened to be collected — which is to say, under memory pressure, in production, once. mypy --strict will not let you write it. You have to name the already-collected branch and decide what it means: skip the entry, drop it from the registry, fall back, or raise a domain error.

That is the whole argument for the type system in miniature. The None is not an inconvenience the checker invented; it is a state the program genuinely has, and the checker is the only thing that makes you handle it before a user does.

💡A WeakValueDictionary is used as a cache: cache[key] = expensive_object. Under what conditions is that cache useless, and what does that tell you about when weak caching is the right pattern? click to reveal

It is useless whenever nothing else holds a strong reference to the value. If the cache is the only owner, the entry evaporates the moment the constructing expression finishes, and every lookup misses. You have built a data structure whose entire content is “objects someone else is currently using”.

Which is exactly what makes it the right pattern for identity maps and deduplication, and the wrong pattern for performance caching. An identity map wants: if this object is currently alive somewhere, give me that one rather than constructing a second. Deduplication (interning your own value objects) wants the same thing. Both are correctness features, and both are satisfied by “alive as long as someone uses it”.

A performance cache wants the opposite guarantee — keep this even though nobody is using it, because it was expensive — which is lru_cache or a TTL cache with an explicit bound. Choosing between them is choosing which failure you prefer: a weak cache fails by missing, a strong cache fails by growing. The mistake is picking one without noticing you made the choice.