Use the Python `functools.cache` Decorator

Learn how to use Python's functools.cache and lru_cache decorators to speed up repeated function calls through memoization.

6 min read

The functools.cache decorator in the Python standard library (added in Python 3.9) automatically memoizes a function: it stores the result of every call and returns the stored result on subsequent calls with the same arguments. This turns repeated expensive computations into near-instant dictionary lookups. It is one of several tools covered in functional programming with functools.

pythonpython
from functools import cache
import time
 
@cache
def expensive_calculation(n):
    time.sleep(1)
    return n * n

With the function decorated, call it twice with the same argument and time each call to see the difference caching makes:

pythonpython
start = time.time()
print(expensive_calculation(42))
print(f"First call: {time.time() - start:.2f}s")
 
start = time.time()
print(expensive_calculation(42))
print(f"Second call: {time.time() - start:.4f}s")

The first call pays the full one-second cost of the sleep, but the second call with the same argument (42) skips straight to the cached result:

texttext
1764
First call: 1.00s
1764
Second call: 0.0001s

The first call takes a full second. The second call with the same argument returns instantly from the cache. No code changes beyond adding @cache were needed.

How caching works

When you decorate a function with @cache, Python creates an internal dictionary mapping argument tuples to return values, so understanding what counts as a distinct key is essential to using it correctly.

pythonpython
from functools import cache
 
@cache
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"
 
print(greet("Maya"))                          # Hello, Maya!
print(greet("Maya", "Hello"))     # Hello, Maya!
print(greet("Maya", "Hi"))            # Hi, Maya!

greet("Maya") and greet("Maya", "Hello") produce the same result but are separate cache entries because the argument tuples differ. The cache key includes all positional and keyword arguments.

lru_cache: bounded caching

functools.lru_cache limits the cache to a maximum number of entries. When the cache is full, the least recently used entry is evicted.

pythonpython
from functools import lru_cache
 
call_log = []
 
@lru_cache(maxsize=3)
def square(n):
    call_log.append(n)
    return n * n

Call square with a sequence that cycles through more distinct values than the cache can hold, then inspect which calls actually ran:

pythonpython
for x in [1, 2, 3, 4, 1, 2, 5]:
    square(x)
 
print("Actual computations:", call_log)
print(square.cache_info())

Every one of the 7 calls shows up in call_log, confirming that none of them actually hit the cache with this cache size:

texttext
Actual computations: [1, 2, 3, 4, 1, 2, 5]
CacheInfo(hits=0, misses=7, maxsize=3, currsize=3)

With maxsize=3, only three results are cached at a time. By the time 4 is computed, the cache is full and the oldest entry (1) is evicted, so the later square(1) and square(2) calls both miss and must be recomputed. A small maxsize like this defeats the purpose of caching if the same values keep cycling in and out.

Parametercachelru_cache(maxsize=N)
Cache limitUnlimitedN entries
Eviction policyNoneLeast recently used
Memory riskCan grow unboundedBounded
Best forFew unique inputsMany unique inputs

Inspecting cache statistics

Both decorators provide .cache_info() for monitoring performance, which is useful for confirming a cache is actually paying off in a running application.

pythonpython
from functools import lru_cache
 
@lru_cache(maxsize=128)
def fetch(key):
    return f"data_{key}"
 
for k in [1, 2, 1, 3, 2, 1]:
    fetch(k)
 
info = fetch.cache_info()
print(f"Hits: {info.hits}")                        # Hits: 3
print(f"Misses: {info.misses}")                # Misses: 3
print(f"Current size: {info.currsize}")   # Current size: 3
print(f"Max size: {info.maxsize}")           # Max size: 128

A hit is a cached return. A miss required actual computation. Monitor these in long-running applications to verify the cache is effective.

Clearing the cache

Call .cache_clear() to invalidate all cached entries.

pythonpython
from functools import cache
 
@cache
def get_config(key):
    print(f"Loading {key}")
    return {"key": key, "value": 42}
 
print(get_config("db"))
print(get_config("db"))
get_config.cache_clear()
print(get_config("db"))

"Loading db" only prints on the first call and again after cache_clear(), since the second call in between is served straight from the cache:

texttext
Loading db
{'key': 'db', 'value': 42}
{'key': 'db', 'value': 42}
Loading db
{'key': 'db', 'value': 42}

After cache_clear(), the next call recomputes the result. Use this when underlying data changes or between test cases.

When caching fails

Caching requires hashable arguments and pure functions.

pythonpython
from functools import cache
 
@cache
def broken(data):
    return len(data)
 
broken([1, 2, 3])

Passing a list as an argument raises an exception immediately, before the function body even runs, because @cache tries to use it as a dictionary key:

texttext
TypeError: unhashable type: 'list'

Lists, dictionaries, and sets are not hashable. Convert them to tuples or frozensets before passing.

Caching also requires the function to be pure: same input always produces the same output. Do not cache functions that depend on external state (current time, database contents, random values) unless you clear the cache when that state changes.

pythonpython
from functools import cache
from datetime import datetime
 
@cache
def current_hour():
    return datetime.now().hour

This function returns the same value forever after the first call, which is wrong. Only cache functions whose results are determined solely by their arguments.

Typed caching

Pass typed=True to lru_cache to treat arguments of different types as separate cache entries.

pythonpython
from functools import lru_cache
 
@lru_cache(maxsize=128, typed=True)
def double(value):
    return value * 2
 
print(double(3))     # 6
print(double(3.0))  # 6.0

With typed=True, double(3) and double(3.0) are cached separately. Without it, they share a cache entry because 3 == 3.0.

Practical example: recursive Fibonacci

The classic example where caching makes an exponential algorithm linear.

pythonpython
from functools import cache
 
@cache
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
 
print(fibonacci(200))

Without caching, this call alone would take longer than the age of the universe to finish. With @cache, it returns instantly because each fibonacci(n) is computed exactly once:

texttext
280571172992510140037611932413038677189525

Without caching, fibonacci(200) would make an astronomical number of recursive calls. With @cache, each fibonacci(n) is computed exactly once, reducing the total work from O(2^n) to O(n).

Common mistakes

Caching functions with side effects. If a function writes to a file, sends a network request, or modifies global state, caching it changes program behavior. Only cache pure functions.

Using cache on methods without considering memory. Each instance creates its own cache. If thousands of short-lived instances are created, unbounded cache can leak memory. Use lru_cache(maxsize=...) for methods.

Caching functions that return mutable objects. If a cached function returns a list and the caller modifies it, the cached list is also modified. Return immutable values (tuples, frozensets) or defensive copies from cached functions.

Forgetting to clear the cache in tests. Cached results persist across test cases. Call func.cache_clear() in test setup or teardown to ensure test isolation.

Rune AI

Rune AI

Key Insights

  • @functools.cache memoizes all function calls with no size limit.
  • @functools.lru_cache(maxsize=N) limits the cache to N entries, evicting the least recently used.
  • Access .cache_info() for hits, misses, and current cache size.
  • Call .cache_clear() to invalidate the entire cache.
  • All arguments must be hashable; unhashable types like lists cause a TypeError.
  • Caching works best on pure functions where the same input always produces the same output.
RunePowered by Rune AI

Frequently Asked Questions

When should I use functools.cache vs lru_cache?

Use `functools.cache` when the number of unique function inputs is bounded and memory is not a concern. Use `functools.lru_cache(maxsize=N)` when many unique inputs are possible and you need to limit memory usage. `cache` is simpler; `lru_cache` gives you more control.

Can I cache methods on class instances?

Yes, but be aware that `self` is part of the cache key. Each instance gets its own cache. For methods, prefer `lru_cache` over `cache` to avoid memory leaks if many short-lived instances are created.

How do I clear a cache?

Call `func.cache_clear()`. This removes all cached entries. Use this when the underlying data changes and cached results are no longer valid, or between test cases to ensure isolation.

Conclusion

functools.cache and lru_cache are the simplest way to add memoization to Python functions. Add one decorator and repeated calls with the same arguments return instantly. Use cache for simple cases, lru_cache when you need size limits or performance statistics, and always call cache_clear() when cached data becomes stale.