Python Caching with `functools.cache` and `lru_cache`

Learn how to speed up Python functions with functools.cache and lru_cache, when caching helps, and how to control cache size and eviction.

7 min read

Python caching stores the result of an expensive function call so the next call with the same arguments returns instantly. Python's functools module gives you two decorators for this: cache and lru_cache. Both turn a function into a lookup table, but they differ in how they manage memory.

Use caching when a function is called many times with the same inputs. Classic cases are recursive algorithms like Fibonacci, repeated database-style lookups in memory, or any pure computation where the same arguments reappear. The first call pays the full cost, and every subsequent call is free.

Caching does not help when arguments are always different. A cache that never hits is just wasted memory. Profile first to confirm the function is actually called often enough with repeated arguments for a cache to pay off, as covered in the article on common Python performance bottlenecks.

The simplest cache: functools.cache

The cache decorator is the no-config option. It remembers every call forever. Python 3.9 added it as a simpler alternative to lru_cache with no size limit.

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

Without the decorator, fibonacci(100) makes over a trillion recursive calls and never finishes. With cache, each value from 0 to 100 is computed exactly once. The function becomes a fast lookup table.

The tradeoff: cache never forgets. If your function has a large or unbounded argument space, the cache grows forever. Use it when the set of possible arguments is small and known, like a few hundred values at most.

Controlling size with lru_cache

lru_cache adds a maxsize parameter. When the cache is full, the least recently used entry is removed. This keeps memory bounded.

pythonpython
from functools import lru_cache
 
@lru_cache(maxsize=128)
def fetch_user(user_id):
    # Simulate an expensive lookup.
    result = compute_user_data(user_id)
    return result

The first 128 unique user IDs fill the cache. When user 129 arrives, the least recently accessed entry is evicted. This pattern fits API response caching, computed properties, and any scenario where recent arguments are likely to repeat.

Pick maxsize based on how many distinct arguments you realistically expect to repeat, not an arbitrary power of 2. Common values are 128, 256, or 512. Setting maxsize=None makes it behave like cache with no limit.

Seeing cache stats

Both decorators expose a cache_info method that tells you how well the cache is working.

pythonpython
@lru_cache(maxsize=128)
def square(n):
    return n * n
 
for i in range(50):
    square(i)        # first call: miss
    square(i)        # second call: hit
 
print(square.cache_info())
# CacheInfo(hits=50, misses=50, maxsize=128, currsize=50)

Hits are calls that returned a cached value. Misses are calls that computed a new value.

A high hit ratio means the cache is working. A low ratio means arguments rarely repeat and the cache is wasting memory.

You can also clear the cache with cache_clear():

pythonpython
square.cache_clear()
print(square.cache_info())
# CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)

Clearing is useful in tests or when the underlying data changes and cached values become stale.

What makes a function cacheable

Only pure functions should be cached. A pure function always returns the same result for the same arguments and has no side effects.

Do cache functions that do CPU-heavy math, string processing, or data transformation where arguments repeat. Do not cache functions that read files, query databases, depend on mutable global state, or return different results for the same arguments.

pythonpython
# Good: pure computation with repeated arguments
@cache
def expensive_math(x, y):
    return sum(i ** 0.5 for i in range(x, y))
 
# Bad: depends on external state
@cache
def read_config(key):
    return config[key]   # config may change

If the external state changes, the cache returns stale data. For those cases, use cache_clear to invalidate the cache when the state changes.

Caching with typed arguments

lru_cache has a typed parameter. When typed=True, arguments that compare equal but have different types are cached separately.

pythonpython
@lru_cache(maxsize=128, typed=True)
def double(x):
    return x * 2
 
print(double(1))     # 2
print(double(1.0))   # 2.0, cached separately from double(1)

Without typed=True, double(1) and double(1.0) share the same cache entry because 1 == 1.0 is True in Python. With typed=True, they are distinct entries. This matters when the return type depends on the argument type.

Caching in practice: a before-and-after

Consider a function that counts word frequencies in a text corpus. Without caching, the same document might be parsed multiple times.

pythonpython
@lru_cache(maxsize=64)
def word_counts(doc_id):
    text = load_document(doc_id)
    counts = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts

The first call to word_counts("chapter-1") parses the document. Every subsequent call to word_counts("chapter-1") returns the cached dictionary instantly. With maxsize=64, the 64 most recently accessed documents stay cached.

If you have a batch process that calls the function thousands of times for a fixed set of documents, the cache hit rate approaches 100 percent after the first pass. The speedup is dramatic for I/O-heavy functions like this.

For loop-level speed improvements, the article on optimizing Python loops and iterations covers techniques that pair well with caching. When the bottleneck is slow repeated computation, caching and loop optimization together can deliver order-of-magnitude improvements.

Rune AI

Rune AI

Key Insights

  • functools.cache memoizes every call without a size limit; use it when the argument space is small and fixed.
  • lru_cache(maxsize=N) limits memory and evicts the least recently used entry when full.
  • Caching helps most with pure functions that are called repeatedly with the same arguments.
  • Never cache functions with side effects or external dependencies.
  • Always measure to confirm the cache hit rate justifies the memory cost.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between functools.cache and lru_cache?

functools.cache is a simpler version of lru_cache with no size limit. It caches every call forever, which is fine for functions with a small, fixed set of possible arguments. lru_cache lets you set a maxsize to limit memory and automatically evicts the least recently used entry when full.

When should I NOT use caching in Python?

Do not cache functions with side effects, functions that depend on external state (like reading a file or a database), or functions whose arguments change every call and never repeat. Caching adds memory overhead, so avoid it when the function is already fast and the cache hit rate would be low.

Conclusion

Caching with functools turns repeated expensive calls into instant lookups. Use cache for functions with a fixed small argument space. Use lru_cache with a sensible maxsize when arguments are unbounded. Always verify the cache is actually helping by measuring before and after. A cache that never hits is just wasted memory.