A cache avoids repeated expensive work but introduces potentially stale data. cachetools provides bounded policies, including TTLCache, which combines expiration with least-recently-used eviction.

Install and define the cache contract

python -m pip install cachetools

Before writing code, name the expensive operation, acceptable staleness, memory budget, and invalidation event. A cache is safe only when callers tolerate an older value for the chosen TTL. Authentication decisions, balances, and inventory reservations often need explicit invalidation or no cache.

from cachetools import TTLCache, cached
from threading import RLock

cache = TTLCache(maxsize=500, ttl=60)
lock = RLock()


@cached(cache=cache, lock=lock)
def get_product(product_id: int) -> dict[str, object]:
    return query_database(product_id)

maxsize counts entries by default, not bytes. Five hundred large documents differ from five hundred integers. Supply getsizeof when value size is the better capacity signal, but remember that size is calculated at insertion. Mutating a cached object later makes accounting inaccurate. Immutable values or defensive copies prevent callers from changing shared state.

Build complete keys

Every input that changes the result belongs in the key: locale, tenant, permissions, rule version, and output format. Normalize equivalent inputs so spelling differences do not create unnecessary entries. Never merge private results across users. Keep keys hashable and small, and exclude tokens, passwords, or raw personal data because diagnostics may expose them.

Methods require attention because self normally enters the key. cachedmethod can obtain a cache from each instance:

from cachetools import TTLCache, cachedmethod
from cachetools.keys import hashkey
from operator import attrgetter


class Catalog:
    def __init__(self) -> None:
        self.cache = TTLCache(maxsize=200, ttl=120)

    @cachedmethod(
        attrgetter("cache"),
        key=lambda self, product_id, locale: hashkey(product_id, locale.lower()),
    )
    def load(self, product_id: int, locale: str) -> dict[str, object]:
        return query_catalog(product_id, locale)

Understand expiration and eviction

TTL uses a monotonic timer by default, avoiding problems when the wall clock changes. Expired entries are unavailable, but storage may be reclaimed only during a later write or by calling expire(). In a mostly read-only process, deliberate maintenance can make memory use easier to control.

When maxsize is reached, TTLCache removes expired entries first and otherwise follows LRU. TTL does not guarantee that an item stays for its full lifetime because capacity pressure can evict it earlier. Expiration is not proactive background cleanup. Application code must remain correct on every miss.

removed = cache.expire()
for key, value in removed:
    release_resource(value)

Do not rely on eviction for an essential business action. Restart or abrupt termination can bypass it.

Control concurrency and stampedes

The lock passed to cached protects cache access, but the decorated function may execute outside that critical section. Some versions support a condition so concurrent callers for the same key wait for one computation. Check the documentation matching the installed release and test this behavior.

For a slow upstream service, use timeouts and bounded concurrency as well. A short TTL plus synchronized traffic can make many workers miss together. Options include bounded expiry jitter, refreshing popular values before expiry, or an external cache with distributed coordination. Do not serve stale values indefinitely merely to hide an outage.

Invalidate deliberately

Time is only one strategy. After updating a product, remove its known key or increment a version included in keys. Versioned namespaces help when many entries depend on one rule. cache.clear() is simple but can create a sudden database load.

Negative caching can store “not found” briefly and protect the source, but use a shorter TTL and distinguish absence from transient failure. Do not cache exceptions indiscriminately: timeout, permission denial, and a genuinely missing record need different handling.

Test without sleeping

Inject a fake timer for deterministic expiration tests:

class Clock:
    now = 0.0

    def __call__(self) -> float:
        return self.now


clock = Clock()
test_cache = TTLCache(maxsize=2, ttl=10, timer=clock)
test_cache["a"] = 1
clock.now = 11
assert "a" not in test_cache

Test hits, misses, expiration, capacity eviction, invalidation, key isolation, and concurrency. Measure hit ratio together with upstream latency and error rate. A high hit ratio is not useful if values are too stale; a low ratio may show that the added complexity is not worthwhile.

The lock protects thread access but does not distribute values across processes. Include every result-changing input in the key, such as user, locale, or rule version. Never let private results collide between users.

An in-memory cache disappears at restart and diverges across workers. Use an external service when instances need a shared view, accepting its operational complexity. Measure hits, misses, latency, and size with the Prometheus Client guide.

Local caching is best for disposable results inside one process. Choose an external cache when workers need shared invalidation, coordinated limits, or values that outlive deployments. Even then, retain the database or authoritative API as the source of truth and design for complete cache loss.

Review the failure modes

Simulate an empty cache at startup, a full cache under load, an unavailable upstream, and two requests for the same cold key. Confirm that a miss never changes correctness and that memory remains bounded. If values own files, sockets, or other resources, manage those resources outside the cache rather than assuming eviction will close them.

Document TTL units beside the configuration and avoid unexplained magic numbers. A useful choice comes from the rate of source changes, cost of a miss, and tolerated staleness. Review that choice when traffic or business rules change.

Do not expose the mutable cache object as a general application dictionary. Wrap it behind a small interface that owns key construction, invalidation, and metrics. This keeps callers from creating inconsistent keys or bypassing synchronization and makes replacement with another cache possible.

Finally, review serialization assumptions when a cached value crosses an API boundary. Returning the same mutable dictionary to several callers can couple requests unexpectedly. Convert domain objects to a stable representation at the boundary, and cache only the layer whose ownership is clear. This small design decision prevents many difficult invalidation bugs.

The official cachetools documentation, accessed July 22, 2026, covers LRU and TTL policies, key functions, and synchronization. Treat cache as a disposable optimization; the database or upstream service remains the source of truth.