functools.lru_cache memoization diagram

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.