NOETRION

Choose a topic

TUTORIAL · 7 MIN READ

Python Caching: Define Keys, Freshness, and Result Ownership

Use bounded caches with explicit versioned keys, inspect hit behavior, and prevent callers from mutating shared cached results.

Reviewed October 9, 2026. Examples target Python 3.12 and use synthetic catalogs and deterministic transformations.

A cached function can return a correct answer for yesterday's dataset while appearing perfectly healthy today. It can also return an object that a previous caller has already modified. Both problems arise when a cache is treated as a decorator that makes code faster, rather than a storage policy with a key, a lifetime, and an ownership contract.

This tutorial uses Python's standard-library caching tools to make those policies visible. It does not present timing benchmarks or claim that caching improves every workload. The examples count actual function executions, which establishes reuse on the fixture without pretending to measure latency, memory savings, or performance under concurrent requests.

1. Identify the function's real inputs

A reusable result must be determined by everything that affects its value. Function arguments are obvious inputs, but a configuration object, dataset, environment variable, or current remote response may also matter. If these influences remain hidden, two calls with identical visible arguments can legitimately need different answers while the cache sees the same key.

Hashability is a technical requirement for cache arguments; it is not proof that the key captures the calculation's meaning. A string identifier may be perfectly hashable while referring to a record that changes over time. Conversely, a tuple can be an appropriate immutable key when its elements describe the complete calculation.

A versioned cache key selects a retained result whose ownership and freshness remain explicit
An original cache lifecycle diagram. Version identity handles changed inputs; an eviction limit controls retained entries, rather than proving freshness. Open full-size diagram.

Start by writing an ordinary-language rule: “For the same dataset revision and item code, return the same immutable label.” That is a stronger contract than “cache this lookup.” It tells reviewers what belongs in the key and what must happen when a dataset revision changes.

2. Observe hits without inventing a benchmark

lru_cache retains recent call results up to a configured number of entries. Its inspection interface reports hits, misses, and current size. These counters are useful for understanding reuse, but they do not measure how much work or time a cache saves. A hit on a cheap function can be less useful than avoiding one expensive miss.

from functools import lru_cache

executions = []

@lru_cache(maxsize=2)
def normalize_code(code):
    executions.append(code)
    return code.strip().casefold()

assert normalize_code(" A ") == "a"
assert normalize_code(" A ") == "a"
assert normalize_code(" B ") == "b"
assert normalize_code(" C ") == "c"
assert normalize_code.cache_info().hits == 1
assert normalize_code.cache_info().misses == 3
assert normalize_code.cache_info().currsize == 2

assert normalize_code(" A ") == "a"
assert executions == [" A ", " B ", " C ", " A "]
assert normalize_code.cache_info().misses == 4
normalize_code.cache_clear()
assert normalize_code.cache_info().currsize == 0
print(executions)

The first repeated call reuses its stored result. Later, the bounded cache has evicted that entry, so the final call executes the function again. This is evidence about the specified sequential fixture, not a production hit-rate forecast.

Policy question What a cache setting can establish What still needs an application rule
How many entries remain? A bounded maxsize limits entries Actual bytes per entry
Can repeated arguments reuse an answer? Stored key lookup enables reuse Whether all real inputs are represented
Is an entry current? No automatic time-to-live policy here Versioning or explicit invalidation
Can a caller change the result? The stored object is returned Immutable results or defensive copying
Did reuse occur? Hit and miss counters expose calls Representative workload interpretation

An unbounded cache has no eviction limit. That can be suitable for a small, stable argument domain, but a request identifier or arbitrary user input can create an ever-growing key domain. Make that domain assumption explicit before choosing unbounded retention.

3. Put revision identity in the key

The next program uses versioned synthetic catalogs. The revision string is part of the lookup key, and each revision refers to a distinct immutable tuple. This makes the same item code in two revisions a deliberately different request.

from functools import lru_cache

catalogs = {
    "revision-1": (("A", "Basic"), ("B", "Plus")),
    "revision-2": (("A", "Starter"), ("B", "Plus")),
}

@lru_cache(maxsize=16)
def label_for(revision, code):
    for candidate, label in catalogs[revision]:
        if candidate == code:
            return label
    raise KeyError(code)

assert label_for("revision-1", "A") == "Basic"
assert label_for("revision-2", "A") == "Starter"
assert label_for("revision-1", "A") == "Basic"
assert label_for.cache_info().hits == 1

@lru_cache(maxsize=4)
def unsafe_tags(code):
    return [code, "reviewed"]

first = unsafe_tags("A")
first.append("caller-change")
assert unsafe_tags("A") == ["A", "reviewed", "caller-change"]
assert unsafe_tags("A") is first

@lru_cache(maxsize=4)
def immutable_tags(code):
    return (code, "reviewed")

local = list(immutable_tags("A"))
local.append("local-change")
assert immutable_tags("A") == ("A", "reviewed")
print(label_for.cache_info(), local)

Revision identity works only if that identity is trustworthy. Replacing the catalog associated with revision-1 while keeping its name would violate the assumption. In a real workflow, assign a new version whenever the underlying data changes, and keep the relationship between version and content stable.

4. Treat returned objects as shared state

The unsafe tag function returns a mutable list. The cache retains and returns that object, so the first caller's append becomes visible to the next caller. The function body is not rerun to restore a clean list. This behavior can contaminate request handling or experimental preprocessing even when the cached calculation itself is deterministic.

Prefer an immutable result when its structure permits it. A tuple of immutable strings provides a simple boundary in this fixture. A caller that needs a mutable list can create a local list from that tuple. A tuple containing mutable dictionaries would not provide the same protection, so review the whole structure rather than only its outer container.

If copying is necessary, keep the cached representation private and copy at the public boundary according to a documented ownership policy. The right copying depth depends on nested state. Testing equality alone is insufficient: a mutated result can remain equal to another reference precisely because both names point to the same object.

5. Normalize keys deliberately

The normalization example keys on the original string. Two strings that normalize to the same code still occupy separate entries if passed as different arguments. Sometimes that is acceptable; sometimes the public API should normalize first and call a private cached function with the canonical code.

Canonicalization must preserve the application's distinction between meaningful inputs. Stripping whitespace or case-folding an identifier is a domain decision, not a free optimization. A filesystem name or case-sensitive external identifier may not permit the same transformation as a human-entered catalog code.

The standard library also documents subtleties in how different argument types and keyword arrangements can be cached. Avoid building correctness on an assumption that every semantically equivalent calling form has one shared entry. A narrow private helper with a consistent positional signature can make the key contract easier to review.

6. Separate concurrency, eviction, and invalidation

The cache machinery keeps its internal structure coherent during concurrent use, but an overlapping miss can still cause the underlying function to execute more than once. Therefore the decorator does not guarantee exactly-once side effects. A function that sends a message, creates a payment, or increments an external counter is not made safe merely by being cached.

Eviction answers which retained entry is discarded when capacity is reached. Invalidation answers which answers are no longer valid. They solve different problems. A frequently accessed entry can remain in a bounded cache indefinitely while becoming stale unless a revision or explicit clearing policy addresses the change.

cache_clear resets the function's cache; it does not coordinate all processes in a distributed service or retract results already given to callers. Design lifecycle handling at the scope where the data changes. This tutorial's local cache is a process-local mechanism, not a distributed consistency protocol.

Cached methods need a lifecycle review too: the instance participates in the call key, so retained entries can keep that instance alive. A cache attached to a class is therefore not merely a speed choice inside one method. Consider how long instances and their associated data should remain reachable, and choose the cache's scope accordingly.

7. Test correctness before measuring performance

Test that the same complete key yields the same value, that a changed revision yields the changed answer, and that caller mutation cannot alter a later public result. Also test unknown identifiers and the cache's reset behavior. These checks establish the storage contract without depending on a particular timing threshold.

Only then measure a representative workload if performance matters. Include the cost of a miss, hit frequency, retained bytes, and the overhead of copying results. A microbenchmark of repeated identical calls does not describe a workload dominated by unique keys or changing revisions.

Caching is dependable when you can explain the complete key, the freshness boundary, the retained object's ownership, and the scope of invalidation. With those decisions in place, the decorator implements a policy that reviewers can understand rather than quietly becoming another source of stale or shared state.

Primary sources