What makes a good cache key, and what goes wrong when you get key design wrong (collisions, missing version/tenant scoping)?
A cache key is a promise that 'this exact input produces this exact output' — break that promise and you serve someone else's data, or someone else's tenant's data, without a single error in the logs.
LEARN BY DOINGPut your intuition to work3 steps
What makes a good cache key, and what goes wrong when you get key design wrong (collisions, missing version/tenant scoping)?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What a cache key actually is
A cache key is the identity of a cached value — the thing that says "this exact input produced this exact output, and you can reuse it as long as I remain valid." A good cache key has three properties: it's deterministic (the same logical request always produces the same key), it's complete (every input that could change the output is reflected in the key), and it's namespaced (it can never collide with a key generated for a different purpose, tenant, or data shape).
Get any one of those three wrong and the cache doesn't just get slower — it gets wrong. A slow cache is a performance bug. A cache with a bad key strategy is a correctness bug, and correctness bugs in caching are especially dangerous because they don't look like bugs: the request succeeds, returns a plausible-looking response, and nobody notices until a customer reports seeing someone else's data.
#Anatomy of a good key
Most production-grade cache keys are built from the same handful of components, layered in order from broadest to narrowest:
| Component | Purpose | Example fragment |
|---|---|---|
| Namespace/prefix | Groups related keys and stops unrelated features from colliding | orders: |
| Version | Lets you invalidate an entire generation of keys after a schema or shape change, just by bumping a number | v3: |
| Tenant/organization id | Prevents one tenant's cached data from ever being served to another, in a multi-tenant system | tenant:acme-corp: |
| Entity + identifier | Names the exact resource the value describes | customer:42: |
| Variant/parameters | Every filter, sort order, page number, or locale that changes the output | status:active:page:1:sort:created_at_desc:locale:es |
#What goes wrong: missing parameters
The most common bug is a key that's missing one of the inputs that actually affects the query. Here's a key builder that looks reasonable but silently ignores half its own function's arguments:
# BAD: the key only encodes customer_id, not status, page, or sortdef cache_key_for_orders(customer_id: int) -> str:return f"orders:{customer_id}"def get_orders(customer_id, status, page, sort):key = cache_key_for_orders(customer_id)cached = cache.get(key)if cached:return json.loads(cached)# ... run the real query with status/page/sort ...
Call get_orders(42, status="active", page=1, sort="date") and it caches under orders:42. Call get_orders(42, status="cancelled", page=1, sort="date") two seconds later, and it reads back the active orders under the same key — a collision between two logically different queries that happen to share a customer id. This isn't a rare edge case; it's what happens the first time someone adds a new filter to an existing endpoint and forgets the cache key needs updating too.
The fix is to fold every parameter that changes the output into the key, either concatenated directly or, once there are more than two or three, hashed together so the key stays a manageable length:
import hashlibimport jsondef cache_key(namespace: str, tenant_id: str, version: str = "v3", **params) -> str:payload = json.dumps(params, sort_keys=True)digest = hashlib.sha256(payload.encode()).hexdigest()[:16]return f"{namespace}:{version}:tenant:{tenant_id}:{digest}"# cache_key("orders", "acme-corp", customer_id=42, status="active", page=1, sort="date")# -> "orders:v3:tenant:acme-corp:7f3a9c1d4e2b8081"
Two details matter here. First, sort_keys=True makes the JSON payload deterministic regardless of the order arguments were passed in — without it, {"a": 1, "b": 2} and {"b": 2, "a": 1} would hash to different keys for what is logically the same request. Second, the digest is truncated to 16 hex characters (64 bits) rather than 4 or 8: truncate too aggressively and the birthday paradox makes accidental collisions likely once you have anywhere near tens of thousands of live keys, which turns "different query, same key" from a bug you introduce into a bug that happens on its own.
#What goes wrong: missing tenant scoping
In a multi-tenant system, leaving the tenant id out of the key is the same class of bug as leaving out a filter parameter — except the blast radius is a data breach instead of a wrong page of results.
# DANGEROUS: no tenant scoping at alldef cache_key_missing_tenant(customer_id: int, status: str) -> str:return f"orders:{customer_id}:{status}"
If customer ids are only unique within a tenant (auto-increment ids per tenant, or ids reused across shards), then customer_id=42 in Tenant A and customer_id=42 in Tenant B produce the exact same key. Whichever tenant's request populates the cache first, the other tenant's request reads it back — one company's order data served to a completely different company, with no error, no exception, no log line that looks abnormal. This is one of the most common real-world causes of cross-tenant data leaks in SaaS products, and it's rarely caught by tests, because a single-tenant test suite has no way to exercise it.
#What goes wrong: missing version
The last common failure shows up after a deploy rather than at request time. If a cache key doesn't embed a version, and a deploy changes the shape of the cached value — a renamed field, a new required field, a changed serialization format — old cache entries written by the previous version are still sitting there, and the new code either crashes trying to deserialize them or silently reads a None/null where it expected a value. Embedding a version in the key (v3: in the examples above) turns that into a non-event: the new code simply never requests the old keys, and they expire off naturally.