07 · Caching¶
Caching trades freshness for speed: store the result of expensive work and serve it
again instead of recomputing. Done well, it turns a page that runs twenty queries into
one that runs none. Done carelessly, it shows users stale data or, worse, other users'
data. We reproduced exactly that leak below with Django's own cache_page decorator.
This lesson covers the API from the bottom up and the invalidation habits that keep
caches correct.
Configuring a backend¶
With no CACHES setting, Django uses a per-process in-memory cache. We printed it:
Backends:
| Backend | Shared between processes/servers? | Use |
|---|---|---|
LocMemCache |
no, each process has its own | development, tests |
RedisCache (built in since Django 4.0) |
yes | the usual production choice |
PyMemcacheCache |
yes | production; simple and fast |
DatabaseCache |
yes | when you can't run a cache server; slower |
FileBasedCache |
per machine | rarely |
DummyCache |
— | disables caching (useful in tests) |
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_URL"), # e.g. redis://cache:6379/1
}
}
We didn't have a Redis server in the environment used to write this lesson, so all
outputs below come from LocMemCache. The API and behaviour are identical across
backends; what differs is that LocMem isn't shared. With four Gunicorn workers, a
LocMem cache means four separate caches, each warmed and invalidated separately, which is
a frequent cause of "I cleared the cache but some requests still show old data".
The low-level API¶
from django.core.cache import cache
cache.get("missing") # None
cache.get("missing", "dflt") # 'dflt'
cache.set("k", {"a": 1}, timeout=1) # seconds; None = forever; 0 = don't cache
cache.get("k") # {'a': 1} ... and after 1.1 s: None
cache.set("n", 0); cache.incr("n"); cache.incr("n", 10) # 1, then 11
cache.get_or_set("answer", expensive, 60)
Our get_or_set called twice ran expensive() once. Values are pickled, so you can
cache dicts, lists and model instances (though caching IDs or plain data is more robust
across deploys).
One subtlety we confirmed: storing None is indistinguishable from a miss with get().
After cache.set("none", None), cache.get("none", "sentinel-default") returned None
(the stored value) while has_key("none") was True; but a plain cache.get("none")
would look like a miss. If None is a legitimate result, cache a sentinel or wrap it.
A typical pattern caches a computed summary:
def stats(request):
data = cache.get("book-stats")
if data is None:
data = dict(Book.objects.values_list("status").annotate(n=Count("id")))
cache.set("book-stats", data, timeout=300)
return JsonResponse(data)
First request: 1 query. Second: 0.
Per-view caching, and the leak¶
cache_page caches a view's entire response:
from django.views.decorators.cache import cache_page
@cache_page(60)
def cached_list(request):
return JsonResponse({"titles": list(Book.objects.values_list("title", flat=True))})
First request: 1 query, response headers Cache-Control: max-age=60 plus Expires.
Second: 0 queries. Then we inserted a book and requested again: still 0 queries and 7
titles. The new book was invisible for up to a minute. That's the deal you make.
Now a view whose output depends on who is asking:
@cache_page(60)
def greeting(request):
name = request.user.username if request.user.is_authenticated else "stranger"
return HttpResponse(f"Hello, {name}")
We requested it as an anonymous visitor, then as ana, then as raj:
Both logged-in users were served the anonymous page. Reverse the order (first visitor
logged in) and everyone would see ana's name, or her account details on a real page.
The cache key is built from the URL plus the headers named in the response's Vary
header. SessionMiddleware does add Vary: Cookie when the session is accessed, but
it does so after the view returns, and cache_page stores the response before that,
so the cached entry had no Vary and one key served everyone. Declaring the dependency
inside the decorator stack fixes it:
from django.views.decorators.vary import vary_on_cookie
@cache_page(60)
@vary_on_cookie
def greeting(request):
...
Better still: don't page-cache personalised pages at all. Cache the expensive,
shared parts (with the low-level API or fragment caching) and render the personal bits
fresh. Also beware that cache_page sets Cache-Control: max-age, so browsers and
CDNs may cache the response too; for anything personal, mark it private
(@cache_control(private=True)) or don't cache it.
Template fragment caching¶
Cache part of a template, keyed by whatever it depends on:
{% load cache %}
{% cache 600 sidebar request.LANGUAGE_CODE %}
... expensive "popular books" sidebar ...
{% endcache %}
{% cache 3600 book_card book.pk book.updated %}
... one book's card ...
{% endcache %}
Extra arguments become part of the key. Including book.updated (an auto_now
timestamp) means editing the book automatically uses a new key: no explicit
invalidation needed.
Invalidation¶
Phil Karlton's well-known line puts cache invalidation among the two hard things in computer science. Practical strategies, from simplest:
- Short timeouts. Accept up to N seconds of staleness. Often the right answer.
- Key versioning by content. Put a version or timestamp in the key (
book.updatedabove, orf"stats:v{settings.STATS_VERSION}"). Old entries simply expire unused. - Explicit deletion on write.
cache.delete("book-stats")after changes, ideally intransaction.on_commitso a rollback doesn't leave the cache cleared for data that didn't change, and a slow commit doesn't let a reader re-cache old data before it lands. Remember Level 3 · 04:update()andbulk_create()don't fire signals, so signal-based invalidation misses them. - Generation counters. Keep
cache.incr("books:generation")and include the generation in every book-related key; bumping it invalidates them all at once.
Whatever you choose, write a test that changes the data and asserts the new value is served.
Other caching layers¶
select_related/prefetch_relatedand query fixes come first. Caching a slow page hides the slow page.@cached_propertycaches a computed attribute for one object's lifetime (one request), no backend involved.- HTTP caching with
ETag/Last-Modified(@condition,ConditionalGetMiddleware) lets browsers revalidate cheaply with304 Not Modified. - The per-site cache middleware (
UpdateCacheMiddleware/FetchFromCacheMiddleware) caches every anonymous GET site-wide. Powerful for mostly-static sites; dangerous if any page varies per user without declaring it.
How It Actually Works¶
Every backend implements the same BaseCache interface. Keys are transformed by
make_key() into "<KEY_PREFIX>:<VERSION>:<key>", so you can isolate environments with
KEY_PREFIX and invalidate everything at once by bumping VERSION. Values are pickled
before storage (Redis and Memcached store bytes).
cache_page wraps the view in CacheMiddleware logic. On a request, it builds a
header key from the URL; stored under that key is the list of headers from the
response's Vary. It then builds the real cache key from the URL plus the values of
those headers in the current request (e.g. the cookie). On a miss, it calls the view and,
if the response is cacheable (GET/HEAD, status 200, no Cache-Control: private or
no-store), stores it and records its Vary list. Because the decorator sits inside
the middleware stack, it sees the response before SessionMiddleware adds Vary:
Cookie, which is the precise mechanism behind the leak we observed.
Common mistakes¶
cache_pageon personalised views withoutvary_on_cookie.- LocMem in production with several processes, giving inconsistent caches.
- Invalidating before commit, or relying on signals that
update()skips. - Caching
Noneand treating it as a miss. - Caching instead of fixing N+1 queries.
- Unbounded key spaces (
f"search:{user_query}") filling memory with one-off keys.
Exercise¶
- Add
@cache_page(60)to your catalogue's list view. Measure queries on the first and second request, then add a book and observe the staleness. - Reproduce the per-user leak with a "Hello, name" view, then fix it with
vary_on_cookie. Write a test that logs in two different users and asserts each sees their own name. - Cache the per-status counts with the low-level API and invalidate them in
on_commitwhenever a book is created or changes status. Test it. - Add a fragment cache around each book card keyed by
book.pkandbook.updated.