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
FormMeaningRange requests
ETagStrong validator: byte-identicalAllowed with If-Range
W/"..."Weak validator: semantically equivalentMust not be used for ranges
Hash of the bodyStrong and correct, but costs a full readGood for immutable assets
Counter or version columnStrong if it changes on every writeCheap; the usual server choice
Last-ModifiedWeak by nature, one-second resolutionAvoid 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.

Hashes inside data structures Content addressing: Git, IPFS and deduplication

Last refreshed 2026-09-18.