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.
Mental Model
@lru_cache is memoization with a memory budget. It stores results in a dictionary keyed by arguments, but when the cache is full, it evicts the least recently used entry. Think of it as a fixed-size notepad: you jot down answers to save work, but when the notepad is full, you erase the oldest note you haven't looked at recently.
python
from functools import lru_cache
Basic Usage¶
```python 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:
```python 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¶
```python @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()¶
```python 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()¶
```python @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+)¶
```python @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:
```python 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:
```python @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:
```python @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¶
```python 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¶
```python 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¶
```python 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¶
```python 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¶
```python 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 |
```python 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¶
```python @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¶
```python
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¶
```python @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:
```python 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¶
```python @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¶
```python 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¶
```python
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)
```python
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)
Exercises¶
Exercise 1.
Write a function expensive_lookup(key) that simulates a slow database call with time.sleep(0.5) and returns key.upper(). Decorate it with @lru_cache(maxsize=32). Call it three times with the same key and verify that only the first call is slow. Print cache_info() to confirm the hit count.
Solution to Exercise 1
import time
from functools import lru_cache
@lru_cache(maxsize=32)
def expensive_lookup(key):
time.sleep(0.5)
return key.upper()
start = time.time()
print(expensive_lookup("hello")) # Slow
print(f"First call: {time.time() - start:.2f}s")
start = time.time()
print(expensive_lookup("hello")) # Fast (cached)
print(f"Second call: {time.time() - start:.2f}s")
print(expensive_lookup("hello")) # Fast (cached)
print(expensive_lookup.cache_info())
Exercise 2.
Demonstrate the effect of typed=True. Write a cached function add_one(x) decorated with @lru_cache(maxsize=128, typed=True). Call it with add_one(1) and add_one(1.0). Print cache_info() and verify that both are cache misses (two separate entries). Then repeat without typed=True and show that 1 and 1.0 share a cache entry.
Solution to Exercise 2
from functools import lru_cache
# With typed=True
@lru_cache(maxsize=128, typed=True)
def add_one_typed(x):
return x + 1
add_one_typed(1)
add_one_typed(1.0)
print(add_one_typed.cache_info()) # misses=2 (separate entries)
# Without typed (default False)
@lru_cache(maxsize=128)
def add_one_untyped(x):
return x + 1
add_one_untyped(1)
add_one_untyped(1.0)
print(add_one_untyped.cache_info()) # misses=1 (shared entry)
Exercise 3.
Write a recursive climb_stairs(n) function (number of ways to climb n stairs taking 1 or 2 steps at a time) and decorate it with @lru_cache(maxsize=64). Compute climb_stairs(50), print the result and cache stats. Then call cache_clear() and verify the cache is reset.
Solution to Exercise 3
from functools import lru_cache
@lru_cache(maxsize=64)
def climb_stairs(n):
if n <= 1:
return 1
return climb_stairs(n - 1) + climb_stairs(n - 2)
print(climb_stairs(50)) # 20365011074
print(climb_stairs.cache_info())
climb_stairs.cache_clear()
print(climb_stairs.cache_info()) # hits=0, misses=0