functools.lru_cache¶
The @lru_cache decorator provides memoization with a Least Recently Used eviction policy. It caches function results and automatically removes the oldest entries when the cache reaches its size limit.
from functools import lru_cache
Basic Usage¶
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Without cache: exponential time O(2^n)
# With cache: linear time O(n)
print(fibonacci(100)) # Instant!
maxsize Options¶
The maxsize parameter controls cache behavior:
from functools import lru_cache
# maxsize=128 (default) — LRU eviction when full
@lru_cache(maxsize=128)
def func1(x):
return x ** 2
# maxsize=None — unlimited cache, never evicts
@lru_cache(maxsize=None)
def func2(x):
return x ** 2
# maxsize=0 — no caching (useful for testing)
@lru_cache(maxsize=0)
def func3(x):
return x ** 2
# Without parentheses (Python 3.8+) — uses default maxsize=128
@lru_cache
def func4(x):
return x ** 2
How LRU Eviction Works¶
@lru_cache(maxsize=3)
def process(x):
print(f" Computing {x}")
return x ** 2
process(1) # Computing 1 → cache: [1]
process(2) # Computing 2 → cache: [1, 2]
process(3) # Computing 3 → cache: [1, 2, 3]
process(1) # Cache hit → cache: [2, 3, 1] (1 moved to end)
process(4) # Computing 4 → cache: [3, 1, 4] (2 evicted — least recently used)
process(2) # Computing 2 → cache: [1, 4, 2] (3 evicted)
Cache Management Methods¶
Every decorated function gains three methods:
cache_info()¶
from functools import lru_cache
@lru_cache(maxsize=128)
def square(x):
return x ** 2
square(1)
square(2)
square(1) # Cache hit
square(3)
info = square.cache_info()
print(info)
# CacheInfo(hits=1, misses=3, maxsize=128, currsize=3)
print(info.hits) # 1 — number of cache hits
print(info.misses) # 3 — number of cache misses
print(info.maxsize) # 128 — maximum cache size
print(info.currsize) # 3 — current number of cached entries
cache_clear()¶
@lru_cache(maxsize=128)
def compute(x):
return x ** 2
compute(1)
compute(2)
print(compute.cache_info().currsize) # 2
compute.cache_clear()
print(compute.cache_info().currsize) # 0
cache_parameters() (Python 3.9+)¶
@lru_cache(maxsize=256, typed=True)
def func(x):
return x ** 2
print(func.cache_parameters())
# {'maxsize': 256, 'typed': True}
typed Parameter¶
By default, arguments of different types that compare equal share cache entries:
from functools import lru_cache
@lru_cache(maxsize=128)
def compute(x):
print(f" Computing {x} ({type(x).__name__})")
return x * 2
compute(3) # Computing 3 (int)
compute(3.0) # No output — cache hit! (3 == 3.0)
With typed=True, different types get separate entries:
@lru_cache(maxsize=128, typed=True)
def compute(x):
print(f" Computing {x} ({type(x).__name__})")
return x * 2
compute(3) # Computing 3 (int)
compute(3.0) # Computing 3.0 (float) — separate entry
Argument Requirements¶
Must Be Hashable¶
All arguments must be hashable because lru_cache uses them as dictionary keys:
@lru_cache
def process(data):
return sum(data)
# Works: hashable types
process((1, 2, 3)) # tuple — OK
process("hello") # str — OK
process(frozenset({1})) # frozenset — OK
# Fails: unhashable types
# process([1, 2, 3]) # TypeError: unhashable type: 'list'
# process({1, 2, 3}) # TypeError: unhashable type: 'set'
# process({'a': 1}) # TypeError: unhashable type: 'dict'
Converting Unhashable Arguments¶
from functools import lru_cache
# Strategy 1: Wrapper that converts to tuple
def process_list(items):
return _process(tuple(items))
@lru_cache(maxsize=128)
def _process(items):
return sum(items)
# Strategy 2: Convert dict to frozenset of items
def process_dict(d):
return _process_dict(frozenset(d.items()))
@lru_cache(maxsize=128)
def _process_dict(items):
return dict(items)
Practical Examples¶
Recursive Dynamic Programming¶
from functools import lru_cache
@lru_cache(maxsize=None)
def coin_change(amount, coins):
"""Minimum coins needed for amount."""
if amount == 0:
return 0
if amount < 0:
return float('inf')
return 1 + min(
coin_change(amount - coin, coins)
for coin in coins
)
coins = (1, 5, 10, 25)
print(coin_change(67, coins)) # 7
Expensive Computation¶
from functools import lru_cache
@lru_cache(maxsize=1000)
def expensive_query(user_id, date):
"""Cache database results for repeated queries."""
return fetch_from_database(user_id, date)
# First call: hits database
result1 = expensive_query("user_123", "2024-01-01")
# Second call: returns cached result
result2 = expensive_query("user_123", "2024-01-01")
Web API Response Caching¶
from functools import lru_cache
import json
@lru_cache(maxsize=256)
def get_exchange_rate(base, target):
"""Cache exchange rates (limited domain)."""
response = requests.get(f"https://api.example.com/rate/{base}/{target}")
return response.json()['rate']
# Avoids repeated API calls for same currency pair
rate = get_exchange_rate("USD", "EUR")
Recursive String Operations¶
from functools import lru_cache
@lru_cache(maxsize=None)
def edit_distance(s1, s2):
"""Levenshtein distance between two strings."""
if not s1:
return len(s2)
if not s2:
return len(s1)
if s1[0] == s2[0]:
return edit_distance(s1[1:], s2[1:])
return 1 + min(
edit_distance(s1[1:], s2), # delete
edit_distance(s1, s2[1:]), # insert
edit_distance(s1[1:], s2[1:]) # replace
)
print(edit_distance("kitten", "sitting")) # 3
lru_cache vs cache¶
| Feature | @lru_cache |
@cache |
|---|---|---|
| Python version | 3.2+ | 3.9+ |
| Cache size | Configurable (default 128) | Unlimited |
| Eviction | LRU (Least Recently Used) | Never |
| Memory | Bounded | Grows forever |
typed parameter |
Yes | No |
| Use case | Large/unknown domains | Small domains, recursive |
from functools import lru_cache, cache
# Equivalent:
@cache
def func1(x): ...
@lru_cache(maxsize=None)
def func2(x): ...
Decision guide:
- Use
@cachefor recursive algorithms with bounded input (fibonacci, DP) - Use
@lru_cache(maxsize=N)for functions with large/unbounded input domains - Use
@lru_cache(maxsize=None)as a pre-3.9 equivalent of@cache
Memory Considerations¶
Monitoring Cache Size¶
@lru_cache(maxsize=1000)
def process(x):
return x ** 2
for i in range(5000):
process(i)
info = process.cache_info()
print(f"Size: {info.currsize}/{info.maxsize}") # Size: 1000/1000
print(f"Hit rate: {info.hits / (info.hits + info.misses):.1%}")
Tuning maxsize¶
# Too small: many evictions, low hit rate
@lru_cache(maxsize=10)
def func(x): ...
# Too large: wastes memory
@lru_cache(maxsize=1_000_000)
def func(x): ...
# Strategy: monitor hit rate and adjust
# Good hit rate (>80%) → maxsize is adequate
# Low hit rate (<50%) → increase maxsize or rethink caching
Clearing Cache for Long-Running Processes¶
@lru_cache(maxsize=1000)
def compute(x):
return expensive_operation(x)
def process_batch(items):
results = [compute(item) for item in items]
# Clear cache between batches to free memory
compute.cache_clear()
return results
Thread Safety¶
lru_cache is thread-safe. The underlying dictionary is protected by a lock:
from functools import lru_cache
from concurrent.futures import ThreadPoolExecutor
@lru_cache(maxsize=128)
def compute(x):
return x ** 2
# Safe to call from multiple threads
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(compute, range(100)))
However, the decorated function itself may still have race conditions if it has side effects.
Common Pitfalls¶
Caching Functions with Side Effects¶
@lru_cache
def log_and_compute(x):
print(f"Computing {x}") # Side effect: only executes on cache miss
return x ** 2
log_and_compute(5) # "Computing 5" — printed
log_and_compute(5) # Nothing printed — cached result returned
Methods and self¶
class MyClass:
@lru_cache(maxsize=128) # Caution: self is part of the cache key
def compute(self, x):
return x ** 2
# Each instance has separate cache entries
# AND instances are kept alive by cache references!
Solution: use __hash__ carefully or prefer cached_property for instance methods.
Mutable Default State¶
# The cache does NOT detect changes to external state
data = [1, 2, 3]
@lru_cache
def process(index):
return data[index] # Caches based on index, not data contents
process(0) # Returns 1
data[0] = 999
process(0) # Still returns 1 (cached!)
Implementation Details¶
- Uses a doubly-linked list for O(1) LRU operations
- Uses a dictionary for O(1) lookups
- Cache key is
(args, kwargs)converted to a hashable form maxsizeshould be a power of 2 for best performance (internal hash table sizing)
# Optimal maxsize values
@lru_cache(maxsize=64) # 2^6
@lru_cache(maxsize=128) # 2^7 (default)
@lru_cache(maxsize=256) # 2^8
@lru_cache(maxsize=1024) # 2^10
Summary¶
| Feature | Details |
|---|---|
| Import | from functools import lru_cache |
| Python version | 3.2+ |
| Default maxsize | 128 |
| Eviction policy | Least Recently Used |
| Arguments | Must be hashable |
| Thread safe | Yes |
| Methods | .cache_info(), .cache_clear(), .cache_parameters() |
Key Takeaways:
@lru_cacheprovides bounded memoization with automatic LRU eviction- Set
maxsizebased on expected input domain (power of 2 is optimal) - Monitor hit rate with
cache_info()to tunemaxsize - Use
typed=Truewhenintandfloatarguments need separate cache entries - Arguments must be hashable — convert lists to tuples, dicts to frozensets
- Thread-safe but beware of side effects in the cached function
- For unlimited caching, use
@cache(Python 3.9+) or@lru_cache(maxsize=None)