Back to Blog
Python

Python cachetools vs functools lru_cache

python cachetools vs functools lru_cache: Compare Python's built-in functools.lru_cache with the cachetools library: API, eviction policies, thread safety, and when to...

cachingcachetoolsfunctoolslru_cacheperformance
Comparison of Python caching approaches with cachetools and functools.lru_cache showing different cache types.

When you need to memoize function results in Python, the standard library gives you functools.lru_cache, while the cachetools package offers a broader set of cache implementations. The choice between python cachetools vs functools lru_cache depends on your eviction policy, thread-safety requirements, and whether you need to cache values with a time-to-live.

The Core Difference Between cachetools and functools.lru_cache

functools.lru_cache is a decorator that caches function return values based on the arguments passed to the function. It uses an LRU (least recently used) eviction policy and is limited to a fixed maxsize. The cache is stored in a dictionary, and the decorator manages the eviction order internally.

cachetools is a third-party library that provides several cache classes, including LRUCache, TTLCache, LFUCache, and RRCache. It also offers decorators like @cached and @cachedmethod that wrap functions and methods with these caches. Unlike functools.lru_cache, cachetools lets you choose the eviction policy and gives you direct access to the cache object, which you can inspect, clear, or share across functions.

The most significant difference is that functools.lru_cache is a single-purpose decorator with a fixed LRU policy, while cachetools is a general-purpose caching toolkit that separates the cache data structure from the decoration logic.

API and Decorator Usage

functools.lru_cache is used directly as a decorator. You can apply it to a function and optionally set maxsize and typed.

from functools import lru_cache @lru_cache(maxsize=128) def compute(x): return x * x

The typed parameter, when True, separates cache entries for different argument types. For example, compute(1) and compute(1.0) would be stored separately.

cachetools provides a cached decorator that takes a cache instance as its first argument. You create the cache separately and pass it to the decorator.

from cachetools import cached, LRUCache, TTLCache cache = LRUCache(maxsize=128) @cached(cache) def compute(x): return x * x

Because the cache object is separate, you can reuse the same cache across multiple functions or methods, and you can inspect its current contents or clear it without touching the wrapped function.

For methods, cachetools provides @cachedmethod, which expects a method that returns the cache object. This is useful when you want each instance of a class to have its own cache.

from cachetools import cachedmethod class DataFetcher: def __init__(self): self.cache = TTLCache(maxsize=100, ttl=60) @cachedmethod(lambda self: self.cache) def fetch(self, key): return expensive_lookup(key)

functools.lru_cache does not have a direct way to attach a cache to an instance without using a closure or a custom wrapper, which makes cachetools more flexible for object-oriented designs.

Cache Types and Eviction Policies

functools.lru_cache only supports LRU eviction. When the cache reaches maxsize, the least recently used entry is discarded. This is a good default for many memoization scenarios, but it does not handle time-based expiration.

cachetools offers multiple eviction policies:

  • LRUCache: least recently used, same as functools.lru_cache.
  • TTLCache: entries expire after a fixed time-to-live (TTL). This is useful for caching data that becomes stale, such as API responses or configuration values.
  • LFUCache: least frequently used, which keeps entries that are accessed often, even if they were not accessed recently.
  • RRCache: random replacement, which evicts a random entry when full. This is rarely used but can be useful for certain probabilistic workloads.

Here is an example of a TTL cache:

from cachetools import TTLCache cache = TTLCache(maxsize=100, ttl=300) @cached(cache) def get_user(user_id): return database.lookup(user_id)

After 300 seconds, the entry for user_id is automatically removed, and the next call will hit the database again. functools.lru_cache has no built-in TTL support, so you would have to implement expiration manually, which is error-prone.

Thread Safety and Concurrency

functools.lru_cache is thread-safe in the sense that concurrent calls to the wrapped function will not corrupt the cache. The decorator uses an internal lock to protect cache mutations. However, the function body itself is not atomic, so if two threads call the function with the same arguments simultaneously, both may execute the function before either result is stored. This is a common memoization race condition.

cachetools cache classes are not thread-safe by default. If you need to share a cache across threads, you must provide a lock. The cached decorator accepts an optional lock argument, or you can use the LockMixin from cachetools to create a thread-safe cache.

from cachetools import LRUCache, cached from threading import Lock cache = LRUCache(maxsize=128) lock = Lock() @cached(cache, lock=lock) def compute(x): return heavy_computation(x)

The lock ensures that cache operations are atomic, but it does not prevent the wrapped function from being executed multiple times for the same arguments. If you need to guarantee single execution per key, you must implement a per-key lock or use a library that supports it.

For most applications, the built-in thread safety of functools.lru_cache is sufficient, but if you need TTL or other eviction policies, you will need to add locking yourself with cachetools.

Performance and Memory Considerations

Both functools.lru_cache and cachetools store cached values in memory. The memory usage is proportional to maxsize and the size of the stored values. functools.lru_cache stores a dictionary mapping arguments to results, and the LRU order is maintained with a linked list. cachetools uses a similar structure for LRUCache, but TTLCache adds a timestamp to each entry, which increases memory overhead slightly.

The performance of both approaches is comparable for lookup and insertion, as they both use hash-based dictionaries. The main performance difference comes from the eviction policy. LRU eviction requires updating a linked list on every access, which is O(1) but has a constant overhead. TTL eviction requires checking timestamps on every access, which is also O(1) but may involve removing expired entries lazily.

If you need to cache a large number of items with a short TTL, cachetools is the better choice because it handles expiration automatically. With functools.lru_cache, you would have to manually clear the cache or implement a custom expiration mechanism, which adds complexity and can lead to memory leaks if not done correctly.

Another consideration is that functools.lru_cache is part of the standard library, so there is no dependency to install. cachetools is a lightweight third-party package with no dependencies, but you must add it to your project's requirements.

Choosing the Right Caching Approach

Use functools.lru_cache when:

  • You need a simple memoization decorator with a fixed-size LRU cache.
  • You want to avoid adding an external dependency.
  • The cached data does not need to expire based on time.
  • You are satisfied with the built-in thread safety.

Use cachetools when:

  • You need TTL expiration or a different eviction policy like LFU.
  • You want to share a cache across multiple functions or methods.
  • You need to inspect or clear the cache programmatically.
  • You are building a class and want per-instance caches with @cachedmethod.
  • You need to control locking explicitly for concurrent access.

In many real-world applications, the decision comes down to whether you need TTL. If you are caching database queries or API responses that can become stale, cachetools with a TTLCache is the natural fit. If you are memoizing pure functions with a known fixed number of results, functools.lru_cache is simpler and has no external dependency.

Handling Cache Invalidation in Production

One aspect that often gets overlooked is explicit invalidation. functools.lru_cache provides a cache_clear() method and a cache_info() method. You can call cache_clear() to wipe the entire cache, but there is no way to remove a single key. If you need to invalidate a specific entry, you have to either rely on the LRU eviction or restructure your code.

cachetools caches expose a pop(key) method and support del cache[key] to remove individual entries. This is useful when you know that a particular key has changed and the cached value is no longer valid. For example, when a user updates their profile, you can remove their entry from the cache.

cache = TTLCache(maxsize=100, ttl=300) @cached(cache) def get_user(user_id): return database.lookup(user_id) # Invalidate a single user cache.pop(user_id)

This granular control is not available with functools.lru_cache, which forces you to either clear the whole cache or wait for eviction. In a production system with frequent updates, the ability to invalidate specific keys is a major advantage of cachetools.

Another production consideration is observability. functools.lru_cache gives you cache_info() which returns hits, misses, maxsize, and currsize. cachetools does not have a built-in statistics method, but because you have direct access to the cache object, you can easily measure its size or implement your own counters by wrapping the cache access. If you rely on the decorator, you can also access the underlying cache via the cache attribute on the wrapped function.

For long-running services, memory leaks from unbounded caches are a common problem. Both libraries require you to set a maxsize to bound memory usage. With functools.lru_cache, if you set maxsize=None, the cache grows indefinitely, which is dangerous in production. With cachetools, you always specify a maxsize, and for TTLCache you also set a ttl, so the cache is inherently bounded. This makes cachetools a safer default for production workloads where memory pressure is a concern.

Finally, consider the maintainability of your code. functools.lru_cache is a one-liner decorator that is easy to read. cachetools requires you to create a cache object and pass it to the decorator, which adds a bit of boilerplate but makes the caching strategy explicit. If you need to change the eviction policy later, you only need to swap the cache class in one place, whereas with functools.lru_cache you would have to rewrite the caching logic entirely. For teams that expect caching requirements to evolve, cachetools offers a more flexible foundation.

python cachetools vs functools lru_cache: Practical Usage an | RYUSLOG DEV