Python's lru_cache on Methods: The Memoizer That Leaks Instances

2026-05-20

This service processes uploaded images. Each ImageCache holds a 50 MB buffer, but it's created in a tight loop and goes out of scope immediately — so memory should stay flat. Production tells a different story: RSS climbs steadily until the pod OOMs around hour six.

from functools import lru_cache

class ImageCache:
    def __init__(self, source_path):
        self.source_path = source_path
        self.buffer = bytearray(50 * 1024 * 1024)  # 50 MB

    @lru_cache(maxsize=256)
    def thumbnail(self, size):
        return self._render(size)

    def _render(self, size):
        return bytes(self.buffer[:size])

def process_uploads(paths):
    for path in paths:
        cache = ImageCache(path)
        for size in (64, 128, 256):
            send(cache.thumbnail(size))
        # cache goes out of scope here. Right?

The Bug

The decorator @lru_cache is applied to the unbound function stored on the class — there is exactly one cache, shared by every instance, and it lives as long as the class does (effectively forever). When you call cache.thumbnail(64), Python passes the bound self as the first positional argument. The cache key becomes the tuple (self, 64), and the cache holds a strong reference to self for as long as that entry survives.

Two consequences compound the damage:

This is invisible in unit tests (you allocate one instance, it stays alive anyway), invisible to __del__ (it never fires), and invisible to gc.collect() (the references aren't cyclic — they're strong, held by a live dict on the class object). It shows up only as a slow leak in production, and only when the workload churns through enough distinct instances to matter.

The Fix

Move the cache onto the instance, so its lifetime matches the instance's lifetime:

from functools import lru_cache

class ImageCache:
    def __init__(self, source_path):
        self.source_path = source_path
        self.buffer = bytearray(50 * 1024 * 1024)
        # Per-instance cache: dies with self.
        self.thumbnail = lru_cache(maxsize=8)(self._thumbnail)

    def _thumbnail(self, size):
        return bytes(self.buffer[:size])

Now the closure over self._thumbnail dies with the instance — no class-level pinning. For zero-argument methods, functools.cached_property is cleaner still. If you genuinely need a class-wide cache, key it on something hashable-by-value (source_path) and never pass self in.

The general rule: any decorator that builds long-lived storage on the function object will outlive every instance that touches it. @lru_cache, @cache, custom memoizers — all the same trap.

Key Takeaway: @lru_cache on a method attaches the cache to the class, not the instance — every self it sees becomes a strong reference held for the program's lifetime, silently turning your memoizer into a leak.

All newsletters