Filter concepts by levelShowing all levels.

Python · Caching

Caching

Why caching exists, in-memory vs. Redis, the three write patterns, TTL/invalidation, stampede protection, warming, distribution, consistency, and failure behavior

  • lru_cachethe fastest cache — in-process, gone on restart
  • Cache-aside · write-through · write-backdiffer only in WHEN the cache is filled relative to a write
  • TTL + invalidationa timer and an on-demand removal — most caches need both
  • Stampede lockone rebuild instead of N, when a hot key expires under load

1.Why caching is needed, and in-memory cache

A cache stores the result of a slow operation so the next request for the same thing skips the work. An in-memory cache keeps that result inside the running process — a dict, or functools.lru_cache — so lookups cost a hash, not a database query or a computation.

Key features

  • A cache trades memory (and staleness risk) for avoiding repeated slow work — a database query, an API call, or a CPU-bound computation
  • An in-memory cache lives inside the running process — a plain dict, or functools.lru_cache for function results
  • In-memory cache is the fastest option (no network hop) but is per-process — a second process or server has its own, empty cache
  • A process restart or deploy wipes an in-memory cache entirely — nothing survives it
  • lru_cache(maxsize=N) evicts the least-recently-used entry once full, bounding memory automatically

Command

python
from functools import lru_cache

@lru_cache(maxsize=128)
def slow_square(x):
    return x * x

A cache saves a slow result in a fast place; an in-memory cache is the fastest kind but lives only in the current process and vanishes on restart.

2.Redis cache

Redis is an in-memory key-value store that runs as its own server, separate from your application process. Multiple app processes or servers share the same Redis cache, unlike an in-memory dict that only one process can see.

Key features

  • Redis runs as a separate server process — reached over the network, not accessed as an in-process object
  • Because it is separate from any one app process, every app instance shares the same cached data — unlike a per-process in-memory cache
  • Values are commonly strings, but Redis also has native hash, list, set, and sorted-set types for structured data
  • SET key value EX 60 sets a key with a 60-second TTL directly, using Redis's own built-in expiry
  • A network hop to Redis is still far faster than a database query, but strictly slower than an in-process dict lookup

Command

python
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.set("user:42:name", "Alice", ex=300)   # ex=300 seconds TTL
name = r.get("user:42:name")
r.delete("user:42:name")

Redis is a shared, network-accessed cache every app instance can read and write — an in-memory dict cannot be shared across processes or servers.

3.Cache-aside, write-through, and write-back

These three patterns differ in when the cache gets filled relative to the database. Cache-aside fills it lazily on a read miss. Write-through fills it immediately on every write, alongside the database. Write-back fills it on write but delays the database write until later.

Key features

  • Cache-aside: application checks cache first; on a miss, reads the database and populates the cache — the most common pattern
  • Write-through: every write goes to the database AND the cache, in the same request — reads are always fresh, writes are slower
  • Write-back (write-behind): writes go to the cache immediately and are flushed to the database later, asynchronously or batched
  • Write-back is fastest for writes but risks data loss if the cache fails before the delayed flush completes
  • Cache-aside never populates the cache on write — a stale cache entry can persist until it expires or is explicitly invalidated

Cache-aside vs. write-through vs. write-back

Cache-aside vs. write-through vs. write-back
PatternOn writeOn readRisk
Cache-asidewrites DB only; cache untouchedcheck cache, else read DB then populate cachestale cache until read-through or TTL expiry
Write-throughwrites DB and cache togetheralways reads cache — it is always freshevery write pays the cache-write cost too
Write-backwrites cache now, DB later (async)reads cache — fresh, since cache led the writeDB write lost if cache fails before flush
Cache-aside vs. write-through (the two most-compared patterns)

Cache-aside

  • Write goes to the database only
  • Cache is filled lazily, on the next read miss
  • Simple, and the default choice for most reads

Write-through

  • Write goes to the database and the cache together
  • Every subsequent read is guaranteed fresh
  • Every write pays the extra cache-write latency
  • Cache-aside
    • Write goes to the database only
    • Cache is filled lazily, on the next read miss
    • Simple, and the default choice for most reads
  • Write-through
    • Write goes to the database and the cache together
    • Every subsequent read is guaranteed fresh
    • Every write pays the extra cache-write latency

Command

python
# Cache-aside read
def get(key):
    if key in cache: return cache[key]
    value = db[key]; cache[key] = value
    return value

# Write-through write
def write_through(key, value):
    db[key] = value; cache[key] = value

# Write-back write
def write_back(key, value):
    cache[key] = value; dirty_queue.append(key)

Cache-aside fills the cache on a read miss; write-through fills it on every write; write-back fills it on write but delays the database write until later.

4.TTL and cache invalidation

TTL (time-to-live) makes a cache entry expire automatically after a fixed duration. Invalidation removes an entry immediately, on demand, usually because the underlying data just changed. A cache needs one or both — otherwise stale data lives forever.

Key features

  • TTL (time-to-live) is a duration after which a cache entry is treated as expired, without any write needing to trigger it
  • A read against an expired TTL entry is normally treated as a miss — the cache repopulates it from the source of truth
  • Invalidation removes or overwrites a specific cache entry immediately, typically right after the matching database write
  • Relying on TTL alone means stale data can be served for up to the full TTL window after a real change
  • Relying on invalidation alone means an entry with no matching write path (or a missed one) can live forever

TTL vs. explicit invalidation

TTL vs. explicit invalidation
MechanismTriggerStaleness windowFailure mode if skipped
TTLa fixed duration elapsesup to the full TTL, even after data changesnone — always expires eventually
Invalidationan explicit write/delete callnear zero — cleared the moment data changesstale forever if the invalidation call is missed
Two ways a stale entry stops being served

TTL expiry

entry auto-expires after a fixed duration

Explicit invalidation

entry removed the moment a write changes it

Next read

miss — repopulated from the source of truth

  1. TTL expiry — entry auto-expires after a fixed duration
  2. Explicit invalidation — entry removed the moment a write changes it
  3. Next read — miss — repopulated from the source of truth

Command

python
class TTLCache:
    def __init__(self):
        self._store = {}   # key -> (value, expires_at)

    def set(self, key, value, ttl_seconds):
        self._store[key] = (value, time.monotonic() + ttl_seconds)

    def get(self, key):
        value, expires_at = self._store.get(key, (None, 0))
        if time.monotonic() > expires_at:
            self._store.pop(key, None)
            return None
        return value

TTL expires an entry automatically after a fixed duration; invalidation removes it immediately on a matching write — most real caches need both.

5.Cache stampede

A cache stampede happens when a popular cache entry expires and many concurrent requests all miss at once, each independently redoing the same expensive rebuild. A per-key lock — letting only the first request rebuild while the rest wait — prevents it.

Key features

  • A stampede happens specifically when many concurrent requests hit the SAME expired or missing key at the same moment
  • Without protection, every one of those requests independently redoes the full expensive rebuild (a DB query, an API call, a computation)
  • A per-key lock lets the first request rebuild while the others block, then all read the now-cached value once it is ready
  • The lock must be re-checked AFTER acquiring it — otherwise a second thread that was waiting also redoes the rebuild once the lock is free
  • A short random jitter added to each TTL spreads out expirations, so many keys are less likely to expire in the same instant

Unprotected vs. lock-protected cache read under concurrency

Unprotected vs. lock-protected cache read under concurrency
ApproachWhat happens on a stampedeRebuild count for 10 concurrent misses
No lockevery thread sees the miss and rebuilds independently10
Per-key lock, no re-checkfirst thread rebuilds; waiters proceed to rebuild too once unblockedup to 10
Per-key lock + re-check insidefirst thread rebuilds; waiters re-check, find it cached, reuse it1
Ten concurrent misses on one expired key, with a per-key lock
first threadother 9 threads,after waitingcache hit —skip rebuild

10 requests miss

same key, same instant

Acquire lock(key)

only one thread gets in first

Re-check cache

waiters find it already filled

Rebuild once

one DB query / API call

  • 10 requests miss — same key, same instant
    • leads to Acquire lock(key)
  • Acquire lock(key) — only one thread gets in first
    • leads to Rebuild once (first thread)
    • leads to Re-check cache (other 9 threads, after waiting)
  • Re-check cache — waiters find it already filled
    • leads to Rebuild once (cache hit — skip rebuild)
  • Rebuild once — one DB query / API call

Command

python
import threading

locks = {}
locks_guard = threading.Lock()

def get_with_lock(key):
    if key in cache:
        return cache[key]
    with locks_guard:
        lock = locks.setdefault(key, threading.Lock())
    with lock:
        if key in cache:      # re-check inside the lock
            return cache[key]
        value = expensive_rebuild()
        cache[key] = value
        return value

A stampede is many concurrent requests all rebuilding the same expired key at once — a per-key lock with a re-check inside collapses that to one rebuild.

6.Cache warming

Cache warming pre-populates a cache with expected data before traffic arrives — usually at deploy or startup — instead of waiting for the first real request to trigger a slow miss.

Key features

  • Warming runs a script or startup hook that populates known-hot keys before real traffic hits the cache
  • It matters most right after a deploy or restart, when an in-memory cache is completely empty (see cold start)
  • Warming targets the highest-value keys — top products, popular pages — not the entire dataset, which would be wasteful
  • Without warming, the first requests after a restart all pay a cache miss simultaneously — which can itself trigger a stampede
  • A read-through cache can approximate warming automatically, but an explicit warm-up script gives control over exactly what loads first

Command

python
def warm_cache_on_startup():
    for key, value in load_top_products().items():
        cache[key] = value

# called once, at process startup, before serving requests
warm_cache_on_startup()

Warming fills a cache with known-hot data before traffic arrives, so the first real requests after a restart do not all pay a miss at once.

7.Distributed caching

A distributed cache spreads data across multiple cache nodes instead of one server, so the dataset can exceed a single machine's memory and survive one node failing. A client library decides which node holds a given key.

Key features

  • Distributed caching spreads keys across multiple nodes, so total cache capacity is not limited to one machine's memory
  • Consistent hashing maps each key to a specific node, so adding or removing a node only reshuffles a fraction of keys, not all of them
  • Redis Cluster is the common way to run Redis itself as a distributed cache, sharding keys across multiple Redis nodes
  • Replication (a primary plus replicas per shard) adds availability — a replica can serve reads or be promoted if the primary fails
  • A distributed cache trades single-node simplicity for horizontal capacity and fault tolerance, at the cost of operational complexity

Command

python
from redis.cluster import RedisCluster

rc = RedisCluster(host="localhost", port=6379)
rc.set("user:42:name", "Alice", ex=300)
name = rc.get("user:42:name")
# the client library routes each key to the correct node automatically

A distributed cache shards keys across multiple nodes via consistent hashing, trading single-node simplicity for capacity beyond one machine and tolerance of a node failing.

8.Cache consistency

Cache consistency is how closely a cached value matches the current value in the source of truth. No caching strategy gives perfect real-time consistency — every one of them accepts some window where the two can disagree.

Key features

  • Strong consistency means a read after a write always returns the new value — expensive, since every write must reach the cache before any read can proceed
  • Eventual consistency means a read may return a stale value for a bounded window, then eventually reflects the write — this is what TTL-based caching gives you
  • Write-through gets close to strong consistency because cache and database update together, in the same request
  • Cache-aside with a long TTL and no invalidation accepts the largest inconsistency window, in exchange for the simplest write path
  • The right consistency level is a product decision, not a technical default — a stock counter tolerates staleness a payment balance cannot

Command

python
# stronger consistency: write-through, cache always matches db
def write_through(key, value):
    db[key] = value
    cache[key] = value

# weaker consistency: cache-aside + TTL, bounded staleness window
def get_cache_aside(key):
    if key in cache: return cache[key]
    value = db[key]; cache[key] = value
    return value

Every caching strategy accepts some staleness window — write-through keeps it near zero, cache-aside with a long TTL accepts minutes; pick based on what the data is used for.

9.Cache failure behavior

When the cache itself is unreachable, the application must decide: fail open (fall back to the database, slower but working) or fail closed (return an error instead of hitting the database). Most caches should fail open.

Key features

  • A cache is meant to be an optimization, not a dependency the application cannot function without
  • Failing open catches a cache error and falls back to the source of truth, at the cost of slower requests during the outage
  • Failing closed returns an error to the caller instead of touching the database — appropriate only if the database cannot absorb the full traffic the cache normally shields it from
  • A cache outage with fail-open still risks a stampede — every request suddenly hits the database directly, same as a mass simultaneous miss
  • Timeouts on the cache client matter as much as error handling — a slow-but-not-failed cache can block requests longer than skipping it outright

Command

python
def get_with_fallback(key, source_of_truth):
    try:
        return cache_get(key)
    except CacheUnavailable:
        return source_of_truth[key]   # fail open

A cache outage should degrade performance (fail open, fall back to the database), not correctness — catch only the specific cache error, not every exception.

Remember

  • A cache trades memory and staleness risk for skipping repeated slow work — it is an optimization, not a source of truth.
  • Cache-aside fills on a read miss; write-through fills on every write; write-back fills on write but flushes the database later.
  • TTL expires an entry on a timer; invalidation removes it immediately on a matching write — most real caches need both.
  • A per-key lock with a re-check inside collapses a cache stampede from N rebuilds to 1.

Pro tip

  • Warm a cache with known-hot keys right after a deploy, so the first requests do not all pay a miss (and risk a stampede) at once.
  • Write-back is fastest for writes but can lose data if the process fails before the delayed flush — never use it for data that cannot tolerate loss.
  • A cache outage should degrade latency (fail open, fall back to the database), not correctness — catch the specific cache error, not every exception.
  • Pick the consistency level the data actually needs — a stock counter tolerates staleness a payment balance cannot.

Next up

  • Testing

Sources

  • functools — docs.python.org/3/library/functools.html
  • threading — docs.python.org/3/library/threading.html
  • Redis docs — redis.io/docs/latest
  • In-process patterns run for real on Python 3.14.3 · checked 2026-08-22

Spotted a mistake on this page? Tell us.