Python functools cache vs lru_cache
python functools cache vs lru_cache: Compare Python's functools.cache and lru_cache decorators: unbounded versus bounded caching, the typed parameter, cache introspect...
Python's functools module ships two decorators that memoize function results: functools.cache and functools.lru_cache. Both store return values keyed by the arguments that produced them, but they differ in how much they remember and which knobs they expose. The practical question behind python functools cache vs lru_cache is usually: which one should I decorate my function with, and what does each one cost at runtime?
What Each Decorator Does
functools.cache is the simpler of the two. It wraps a function so that repeated calls with the same arguments return the previously computed value instead of re-executing the body.
from functools import cache @cache def square(x): return x * x
functools.lru_cache does the same thing, but it accepts configuration parameters that control how many results are retained.
from functools import lru_cache @lru_cache(maxsize=128) def square(x): return x * x
Both decorators require that all arguments be hashable, because the cache is a dictionary keyed by the argument tuple. Lists, dictionaries, and other unhashable values raise a TypeError when you call the decorated function.
The Core Difference: Bounded vs Unbounded Storage
The most important distinction is memory behavior. functools.cache never evicts entries. Every distinct set of arguments you call the function with adds a new entry to the cache, and those entries stay for the lifetime of the process. This is equivalent to writing @lru_cache(maxsize=None).
functools.lru_cache with a finite maxsize keeps only the most recently used results. When the cache is full and a new call comes in, the least recently used entry is discarded to make room. The default maxsize is 128, which is a reasonable starting point for many workloads.
The tradeoff is direct: cache gives you the best hit rate because it forgets nothing, but it can grow without bound. lru_cache caps memory usage at the cost of occasionally recomputing a result that was evicted.
from functools import lru_cache # Functionally identical to functools.cache @lru_cache(maxsize=None) def fetch_user(user_id): return db.lookup(user_id)
If you already use lru_cache(maxsize=None), switching to cache is purely cosmetic. The behavior is the same, and cache reads more clearly when your intent is an unbounded memoization.
The typed Parameter
lru_cache accepts a typed argument that cache does not expose. When typed=True, the cache distinguishes between argument values of different types, even if they compare equal.
from functools import lru_cache @lru_cache(typed=True) def describe(x): return f"{type(x).__name__}: {x}" describe(1) # cached under int 1 describe(1.0) # cached separately under float 1.0
Without typed=True, 1 and 1.0 hash to the same key and would collide. That is usually what you want, but it becomes a bug when a function behaves differently for int and float inputs, or when True and 1 must be treated as distinct. Because cache has no typed option, you must use lru_cache whenever type-sensitive caching matters.
Inspecting and Clearing the Cache
Both decorators attach the same introspection methods to the wrapped function: cache_info() and cache_clear().
from functools import lru_cache @lru_cache(maxsize=64) def load_config(env): return parse_config_file(f"config.{env}.yaml") load_config("prod") load_config("prod") print(load_config.cache_info()) # CacheInfo(hits=1, misses=1, maxsize=64, currsize=1)
cache_info() returns a CacheInfo named tuple with four fields: hits, misses, maxsize, and currsize. This is the primary tool for deciding whether a cache is actually helping. If the hit rate is low and currsize stays pinned at maxsize, the cache is thrashing and you should either raise maxsize or reconsider whether memoization fits the access pattern at all.
cache_clear() empties the cache. It is useful in tests and in long-running processes where cached data may become stale after a configuration change.
lru_cache also provides cache_parameters(), which returns a dictionary of the values passed to the decorator, such as maxsize and typed. cache does not have this method because it accepts no parameters.
Choosing Between cache and lru_cache
The decision is driven by three questions: how many distinct argument combinations can occur, whether type-sensitive caching is required, and what Python version you target.
Use functools.cache when the set of possible arguments is small and known in advance, such as a lookup table over a fixed enum, or when you want the simplest possible memoization and memory growth is not a concern. Use lru_cache when arguments are drawn from a large or unbounded space, such as user IDs, timestamps, or arbitrary strings, because the bounded cache prevents the process from accumulating entries indefinitely.
Use lru_cache with typed=True when the function's result depends on the runtime type of its arguments, not just their equality. This is common in numeric code where int and float paths differ, or in dispatch functions that branch on type.
Python version also matters. functools.cache was added in Python 3.9. If you support older versions, lru_cache(maxsize=None) is the only way to get an unbounded cache. lru_cache itself has been available since Python 3.2, so it is the safer choice for code that must run on a wider range of interpreters.
| Feature | functools.cache | functools.lru_cache |
|---|---|---|
| maxsize | None (unbounded) | Configurable, default 128 |
| typed parameter | Not available | Available |
| Eviction | Never | LRU when maxsize is reached |
| Added in | Python 3.9 | Python 3.2 |
| cache_info / cache_clear | Yes | Yes |
Memory and Production Considerations
Unbounded caching is the main operational risk with functools.cache. In a long-running service that receives requests with high-cardinality inputs, the cache grows with every new argument value and never shrinks. A function that accepts a timestamp or a request ID will eventually store millions of entries, consuming memory proportional to the number of distinct calls.
lru_cache with a finite maxsize bounds that growth. The underlying implementation uses an ordered dictionary to track recency, so eviction is O(1) per operation. The memory footprint stays flat once the cache reaches capacity, which makes it predictable in production.
There is a subtle runtime cost difference worth knowing. lru_cache maintains recency order on every hit, which involves moving an entry to the end of the ordered dict. cache does not track recency at all, so a hit is a plain dictionary lookup. For a cache that is never going to evict, cache is marginally cheaper per operation. This is a mechanism-level difference, not a measured benchmark; in most applications the difference is negligible compared with the work inside the function body.
Thread Safety and Concurrency
Both decorators are safe to use from multiple threads in the sense that they will not corrupt the internal dictionary. lru_cache protects cache updates with a lock, which serializes concurrent writes. cache relies on the atomicity of the underlying dictionary operations in CPython and does not add explicit locking.
For most use cases this distinction does not matter. The decorated function still executes outside the lock in both cases, so concurrent calls with different arguments can run in parallel. The lock only guards the bookkeeping of inserting and evicting entries.
If you need strict guarantees about concurrent cache updates on an alternative Python implementation, lru_cache is the more conservative choice because its locking behavior is documented. For a single-threaded application or a GIL-protected CPython script, cache is fine.
Common Pitfalls
Memoization changes the execution model of a function, and both decorators share the same failure modes.
Unhashable arguments are the most frequent error. Calling a cached function with a list or a dictionary raises TypeError: unhashable type. If you need to cache on a list, convert it to a tuple before calling, or design the function to accept hashable inputs.
Functions with side effects should not be cached. If the function writes to a log, increments a counter, or sends a network request, memoization will suppress those effects on repeated calls. Cache only pure functions whose return value is fully determined by their arguments.
Non-deterministic functions are another trap. A function that reads the current time, a random value, or an external API response will return stale data from the cache. If freshness matters, either avoid memoization or clear the cache explicitly with cache_clear() when the underlying data changes.
Mutable default arguments interact badly with caching as well. A cached function that mutates a default list or dictionary will have those mutations persist across calls, and because the cache suppresses re-execution, the mutation may only happen once. This is the same class of bug as the classic mutable-default-argument problem, but caching makes it harder to notice because the function body runs less often.
Finally, remember that the cache key is built from the argument tuple, not from the function's semantic meaning. Two calls that pass equal but not identical arguments, such as 1 and 1.0, collide unless you use typed=True. Decide deliberately whether that collision is correct for your function before you choose between cache and lru_cache.