Skip to content
← All articles

lru_cache on methods: the leak that profiles as 'objects that should be dead'

The cache lives on the class, and self is part of the key — so it holds a strong reference to every instance it has ever seen. Verified: after del and gc.collect(), the instance is still alive.

This looks harmless and is the most expensive four characters in the file:

class Digest:
    def __init__(self, salt: int) -> None:
        self.salt = salt

    @lru_cache(maxsize=128)
    def value(self, n: int) -> int:
        return (self.salt + n) ** 2

lru_cache is applied to the function, at class-creation time, so there is exactly one cache and it lives on the class. The method is called as instance.value(n), which means the cache key is (instance, n)self is an ordinary first argument as far as the decorator is concerned.

Two consequences follow, and both are bugs.

It leaks

The cache holds a strong reference to every self it has ever seen. In a long-lived service that caches something per request object, per session, per connection, the instances never become garbage. Verified: after del h and gc.collect(), a weakref to the instance is still live and the instance is still visible in cache_info().

The fingerprint in a profile is distinctive and confusing: a growing population of objects that nothing in your code references, held by a functools internal, with no obvious owner. People spend hours looking for a missing close().

maxsize is global across instances

There is one cache of 128 entries for the whole class, not 128 per instance. Ten live objects share it, so each effectively gets 12 slots and they evict each other. The hit rate collapses in production — where there are many instances — while looking perfect in a unit test, where there is one.

So it is a correctness-of-performance bug as well as a memory bug, and the two failure modes point in opposite directions: too much retention, too little caching.

💡self is part of the key, so the cache needs Digest to be click to reveal

hashable. What happens to the whole scheme if Digest defines __eq__? Defining __eq__ without __hash__ sets __hash__ = None, so the class becomes unhashable and every cached call raises TypeError: unhashable type: 'Digest'. Loud, immediate, easy to fix.

Defining both is much worse. Now two distinct but equal instances share a cache entry. Digest(salt=1) and a second Digest(salt=1) are different objects with the same state, so the second one silently receives values computed by and for the first. If the class is genuinely immutable that is merely surprising; if any mutable state feeds the computation, the cache returns stale results with no way to notice.

This is the deeper reason the pattern is wrong: caching on self couples your memoisation to your equality semantics, which are two completely unrelated design decisions.

The fix

Give each instance its own cache, so it dies with the instance:

class Digest:
    def __init__(self, salt: int) -> None:
        self.salt = salt
        self._memo: dict[int, int] = {}

    def value(self, n: int) -> int:
        if n not in self._memo:
            self._memo[n] = (self.salt + n) ** 2
        return self._memo[n]

A plain dict is usually enough and is trivially readable. If you want LRU eviction per instance, build the cached callable in __init__ (self._value = lru_cache(maxsize=128)(self._compute)) — that closes over self and creates a reference cycle, which the cyclic collector handles, so the instance still dies at the next gc.collect().

For a class-wide cache that genuinely should be shared, make the method a @staticmethod or a module-level function and cache that — then self is not in the key and there is nothing to leak.

The type checker says nothing

Not a warning, on any of it. Combined with the signature erasure from the previous item, a cached method is doubly invisible: its parameters are not checked, and its retention behaviour is not expressible. This is a good place to internalise the general point — --strict proves things about types, and “this object outlives its scope” is not a type.

💡Where else does this pattern appear? @cached_property also click to reveal

caches per method — is it affected? No, and the contrast is instructive. cached_property stores the computed value in the instance’s __dict__, under the property’s name. The cache is therefore part of the instance, it dies with the instance, and there is no key involving self at all.

The family resemblance is misleading in the other direction too: cached_property has its own hazards (it needs a __dict__, so it crashes on a __slots__ class, and the per-property lock was removed in 3.12 so it can now compute twice under threading). Different decorator, different failure modes.

The general lesson is to ask where does the cached value live? for every memoisation tool you adopt. Class-level storage keyed on the instance leaks; instance-level storage does not.