Why the cache becomes a memory leak
A decorator on a method runs once, when the class body executes. lru_cache wraps the plain function and stores one cache dictionary inside that wrapper, so there is one cache per method per class, shared by every instance. When you call report.total(), the wrapper receives report as an ordinary first argument and builds the key from all the arguments. The key is therefore (report,), and the dictionary now holds report strongly.
Nothing ever removes that reference except eviction or cache_clear(). The class lives for the whole process, the wrapper lives on the class, and the cache lives in the wrapper. Every Report the method was called on stays reachable, along with everything that Report holds: its rows, its database handle, its parsed payload. Reference counting is doing its job perfectly; you told it to keep them.
The neighbouring page on when a decorator runs covers the decoration-time mechanics. This page is about proving the leak in a running service and choosing a fix that does not create a new one.
Proving it before you change anything
Two checks settle it in a minute. cache_info() on the wrapper reports currsize, and a currsize that climbs with requests on a method of a per-request object is the leak in one number. The stronger proof is a weak reference, because it shows that the instance survives a full collection.
import functools, gc, weakref
class Report:
def __init__(self, rows):
self.rows = rows
@functools.cache
def total(self):
return sum(self.rows)
r = Report(list(range(1_000_000)))
r.total()
probe = weakref.ref(r)
del r
gc.collect()
print(probe() is None)
print(Report.total.cache_info().currsize)
Static analysis catches it too. flake8-bugbear's B019 rule, also implemented in Ruff, flags lru_cache and cache applied to methods for exactly this reason, which is a cheap guard to add once you have been bitten.
Bounding the cache is not a fix
The default maxsize of lru_cache is 128, and a common response is to keep or lower it. That turns an unbounded memory leak into a bounded one: up to 128 whole instances pinned at all times, each with everything it references. If each Report holds a few megabytes of rows, that is a few hundred megabytes of memory nobody is using, and it never shrinks. The cache is also still shared across instances, so cache_clear() to invalidate one object's result wipes everyone's.
The per-instance options, and the catch in each
functools.cached_property, available since Python 3.8, is the right default for a method with no arguments. It computes the value once and stores it in the instance's own __dict__, so the cached value dies with the instance. Invalidate it with del obj.total. It needs a writable instance __dict__, so a class with __slots__ must list __dict__ in its slots or it cannot use it. And since Python 3.12 it no longer takes an internal lock, so two threads hitting a cold property at once may both compute it.
A dictionary on the instance covers methods with arguments. Store self._totals = {} in __init__ and look up by the arguments only. It is plain and explicit, and the memory is released when the instance goes.
A per-instance lru_cache built in __init__, written as self.total = functools.lru_cache(maxsize=None)(self._total), also removes self from the key, because the wrapped bound method already carries it. But the bound method references self, and self.__dict__ now references the wrapper, so the instance sits in a reference cycle. It is freed only when the cyclic garbage collector runs, not when the last outside reference disappears. That is usually acceptable, and worth saying so you are not surprised by memory that drops in steps.
A module-level cached function of plain values, such as _total(tuple(rows)), caches on data rather than on objects. It is the right choice when many instances share inputs, and the size bound now matters for the values themselves.
A weakref.WeakKeyDictionary keyed on the instance works only if instances are hashable and weak-referenceable, and only if the cached value does not refer back to the instance. A value that holds its own key keeps the entry alive forever, which reintroduces the leak.
The hashing detail that turns a leak into a wrong answer
The cache compares keys with __hash__ and __eq__, and that is where a correctness bug hides behind the memory bug. A class that defines __eq__ without __hash__ has __hash__ set to None, so the cached method raises TypeError on the first call, which is at least loud. A class with value-based equality, for example two Order objects equal when their ids match, is worse. The second instance finds the first one's entry and gets its cached result, even if its own fields have changed since. The candidate who mentions this has understood that the cache is keyed on what self compares equal to, not on which object it is.
lru_cache on a method caches on the class, so its keys keep every self alive. Prove it with a weak reference, then move the cache onto the instance with cached_property or an instance dictionary, or key it on plain values instead of objects.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).