Cache-Aside, Read-Through, Write-Through & Write-Behind
The four core caching patterns compared, with Python examples and a decision table for when to use each.
Four core caching patterns
How your application reads and writes through a cache determines its consistency, latency and failure behaviour. Four patterns cover almost every real system.
1. Cache-aside (lazy loading)
The application manages the cache itself:
- Look in the cache.
- On a miss, read from the database and store the result in the cache.
- On a write, update the database and delete the cache entry.
It's the most common pattern — and what most people mean by "adding Redis".
import time
class FakeDB:
"""Stands in for a slow database and counts how often it's queried."""
def __init__(self):
self.rows = {1: {"id": 1, "name": "Intro to Python"}}
self.queries = 0
def get(self, key):
self.queries += 1
return self.rows.get(key)
def put(self, key, value):
self.rows[key] = value
class TTLCache:
def __init__(self):
self.store = {}
def get(self, key):
item = self.store.get(key)
if item is None or item[1] < time.monotonic():
return None
return item[0]
def set(self, key, value, ttl):
self.store[key] = (value, time.monotonic() + ttl)
def delete(self, key):
self.store.pop(key, None)
db, cache = FakeDB(), TTLCache()
def get_course(course_id):
key = f"course:{course_id}"
course = cache.get(key)
if course is None: # miss → load and populate
course = db.get(course_id)
cache.set(key, course, ttl=300)
return course
def update_course(course_id, data):
db.put(course_id, data) # 1. write the source of truth
cache.delete(f"course:{course_id}") # 2. invalidate (don't update) the cache
for _ in range(5):
get_course(1)
print("db queries after 5 reads:", db.queries)
update_course(1, {"id": 1, "name": "Python for Beginners"})
print(get_course(1)["name"], "| db queries:", db.queries)
Output:
db queries after 5 reads: 1
Python for Beginners | db queries: 2
Five reads cost one query. After the update, the next read misses, reloads the new value, and the cache is warm again.
Why delete instead of update on writes? If two requests update the same item concurrently, "set cache" calls can arrive out of order and leave an old value cached. Deleting is simpler and self-healing.
Pros: only requested data is cached; a cache outage just makes things slower. Cons: the first request after a miss is slow; there's a short window where a stale value can be read.
2. Read-through
The cache itself loads from the database on a miss, using a loader function you provide. The application only talks to the cache. It's the same idea as cache-aside, moved into a library or caching layer — cleaner code, but you depend on the cache's loading behaviour.
3. Write-through
Every write goes to the cache and the database together, synchronously:
def update_course_write_through(course_id, data):
db.put(course_id, data)
cache.set(f"course:{course_id}", data, ttl=300)
Pros: the cache is always warm and fresh for written data. Cons: every write pays for two operations; you may cache data that's never read; out-of-order concurrent writes need care (use versions, or fall back to invalidation).
4. Write-behind (write-back)
Writes go to the cache first and are flushed to the database later, often in batches.
Pros: extremely fast writes; batching reduces database load (great for counters, analytics, view counts). Cons: if the cache crashes before flushing, data is lost; harder to reason about. Use it only for data you can afford to lose or rebuild.
Choosing a pattern
| Pattern | Read latency | Write latency | Consistency | Risk | Typical use |
|---|---|---|---|---|---|
| Cache-aside | Slow on miss | Normal | Good with invalidation | Brief staleness | Default choice for most apps |
| Read-through | Slow on miss | Normal | Same as cache-aside | Depends on library | Caching frameworks, CDNs |
| Write-through | Fast | Slower | Strong for cached keys | Wasted cache space | Read-after-write critical data |
| Write-behind | Fast | Fastest | Eventual | Data loss on failure | Counters, metrics, logs |
Eviction policies
When the cache is full, something must go:
- LRU (Least Recently Used) — evict what hasn't been used longest. The most common default.
- LFU (Least Frequently Used) — evict the least popular; good for stable popularity.
- FIFO — evict the oldest insert; simple but ignores usage.
- TTL-based — entries expire on schedule regardless of space.
Redis supports these via its maxmemory-policy setting (for example allkeys-lru). You'll build an LRU cache from scratch later in this tutorial.
Common mistakes
- Updating the cache before the database — if the DB write fails, the cache holds data that never existed.
- Using write-behind for important data like orders or payments.
- Forgetting to cache misses ("not found"), so repeated lookups for missing IDs hit the database every time.
- Inconsistent key naming — use a scheme like
entity:id:version.
Key takeaways
- Cache-aside is the default: read-through on miss, delete on write.
- Write-through keeps caches fresh at the cost of slower writes.
- Write-behind is fastest but can lose data — only for non-critical writes.
- Combine every pattern with TTLs and a sensible eviction policy.