
If a function does the same expensive work twice with the same inputs, you are burning CPU for nothing. functools.lru_cache turns any pure function into a memoized one with a single decorator — no manual dict, no bookkeeping. But it has sharp edges that bite the moment your arguments stop being simple.
The basic pattern
Wrap a slow, deterministic function and Python keeps the last N results keyed by arguments:
import time
from functools import lru_cache
@lru_cache(maxsize=128)
def fetch_user(user_id: int) -> dict:
time.sleep(0.5) # pretend this is a DB/HTTP call
return {"id": user_id, "name": f"user-{user_id}"}
# First call hits the source (0.5s); the next 127 repeats are instant.
print(fetch_user(1))
print(fetch_user(1)) # served from cache
maxsize bounds memory. Set maxsize=None for an unbounded cache (careful — it grows forever). Use cache_info() to confirm it is actually helping:
print(fetch_user.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)
Gotcha 1: arguments must be hashable
lru_cache keys on the arguments with a hash table. Pass a list or dict and you get a confusing TypeError: unhashable type — not at decoration time, only when you call it:
@lru_cache(maxsize=128)
def total(nums):
return sum(nums)
total([1, 2, 3]) # TypeError: unhashable type: 'list'
Fix it at the call site by converting to hashable types, or normalize inside the wrapper:
total(tuple([1, 2, 3])) # works
@lru_cache(maxsize=128)
def total(*nums): # *args are already a tuple
return sum(nums)
total(1, 2, 3) # works, and caches per argument tuple
For sets, use frozenset; for dicts, tuple(sorted(d.items())).
Gotcha 2: int and float are NOT the same key
By default 1 and 1.0 are treated as different keys. If your function is mathematically identical for both, pass typed=True to keep them separate, or normalize the input:
@lru_cache(maxsize=128, typed=True)
def scale(x):
return x * 2
# scale(1) and scale(1.0) now occupy two cache slots on purpose
Gotcha 3: never cache side effects or stale data
Only memoize pure functions. Caching a call that sends an email, writes a file, or reads live config will replay the old result and skip the real work:
@lru_cache(maxsize=128)
def get_config():
return load_config_from_disk() # do NOT cache — file may change
When the underlying data changes, clear explicitly:
fetch_user.cache_clear() # wipe all cached entries for this function
When to reach for it
- Repeated calls with the same inputs (lookups, parsers, recursive DP).
- CPU-bound pure computation, not I/O you should parallelize instead.
- Small, bounded argument space (otherwise memory grows).
Skip it for functions with mutable args, non-deterministic output, or heavy side effects. For time-based expiry, layer lru_cache with a TTL check or reach for cachetools.TTLCache.
TL;DR
@lru_cache is the cheapest performance win in the stdlib — but only on hashable, pure, deterministic calls. Check cache_info(), never cache side effects, and cache_clear() when the world changes.