Cache keys, ETags and shard keys
Hashing to build cache namespaces, weak and strong ETags, cache-busting strategies, partition-key selection, and the collision risk you accept in a hot path.
Building a cache key
A cache key must include every input that changes the output. Miss one — a locale, a feature flag, the requesting user's permissions — and you serve one user's data to another.
import hashlib, json
def cache_key(namespace, **parts):
# sort the parts so key order cannot change the key
body = json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)
digest = hashlib.sha256(body.encode()).hexdigest()[:32]
return f"{namespace}:{digest}"
k1 = cache_key("page", user=7, locale="en-GB", flags=["beta"], path="/pricing")
k2 = cache_key("page", locale="en-GB", path="/pricing", flags=["beta"], user=7)
assert k1 == k2 # order-independent
k3 = cache_key("page", user=7, locale="fr-FR", flags=["beta"], path="/pricing")
assert k1 != k3- Include the namespace and a version so a deploy can invalidate everything cleanly.
- Hash long key material rather than embedding it: keys have length limits and long keys waste memory.
- Truncating the digest to 128 bits is safe for cache keys; the collision risk is negligible and the keys are short.
- Never build a cache key from a value that is not available on invalidation, or you cannot compute which key to delete.
ETags: weak and strong
GET /report.pdf HTTP/1.1
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: public, max-age=300
GET /report.pdf HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
HTTP/1.1 304 Not Modified| Form | Meaning | Range requests |
|---|---|---|
ETag | Strong validator: byte-identical | Allowed with If-Range |
W/"..." | Weak validator: semantically equivalent | Must not be used for ranges |
| Hash of the body | Strong and correct, but costs a full read | Good for immutable assets |
| Counter or version column | Strong if it changes on every write | Cheap; the usual server choice |
| Last-Modified | Weak by nature, one-second resolution | Avoid as the only validator |
// server: derive an ETag cheaply from the stored version
const etag = '"' + crypto.createHash("sha256")
.update(String(row.updatedAt.getTime()) + ":" + row.revision)
.digest("hex").slice(0, 32) + '"';
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.set("ETag", etag).json(row);Shard keys and hot partitions
- A hash of the partition key spreads writes evenly, but destroys range scans on that key.
- A composite key of tenant plus a hashed suffix keeps locality per tenant while sharing load across nodes.
- A low-cardinality key such as a status or a country code produces a hot partition no matter how good the hash is.
- Collisions in a shard key are not a correctness bug; two different records simply share a node.
- Resharding is expensive, so pick a key with room to grow and version the scheme from day one.
import hashlib
def partition(tenant: str, record_id: str, buckets: int = 64) -> str:
"""Keep a tenant's records on a small set of buckets, spread across many."""
slot = int(hashlib.sha256(f"{tenant}:{record_id}".encode()).hexdigest(), 16) % buckets
return f"{tenant}-{slot:02d}"💡
In a hot path a truncated hash saves memory but concentrates collision risk. Use at least 64 bits for anything that must stay unique, and expect a birthday collision once you approach roughly four billion keys at 64 bits.
FAQ
Should the ETag be a hash of the response body?
It is the most correct strong validator, but it forces you to produce the whole body before answering. A version counter or an updated-at timestamp is usually cheaper and still strong if it changes on every write.
Why does my cache serve one user's data to another?
The key is missing a varying input, most often a user id, a locale or a permission scope. Enumerate every input to the response and assert it appears in the key.
Related
Hashes inside data structures Content addressing: Git, IPFS and deduplication
Last refreshed 2026-09-18.