Filter concepts by levelShowing all levels.

Django · Caching

Caching

The four levels, Redis and Memcached, keys and TTL, and the five things that go wrong afterwards

  • Cache at the narrowest useful levelthe wider the level, the more of the page goes stale at once
  • The key you write is not the key storedKEY_PREFIX separates tenants; VERSION separates generations of your data
  • A stampede is caused by a SUCCESSFUL cachethe more popular the key, the bigger the herd waiting on its expiry
  • cache.get() returns None for two things"not cached" and "cached as absent" — you need a sentinel to tell them apart
  • Invalidate on commit, not inside the transactiona concurrent read repopulates the pre-write value otherwise

1.Per-site, per-view, template fragment, and the low-level API

Django gives you four places to cache, and they differ in how much of the response they store. **Per-site** caches whole pages through two middleware, and only for `GET`/`HEAD` requests that returned 200. **Per-view** does the same for one view, via `@cache_page(seconds)`. **Template fragment** caches a block inside a template with `{% cache 500 sidebar %}`, so the surrounding page stays dynamic. **The low-level API** — `cache.get()`, `cache.set()`, `cache.get_or_set()` — caches any value you choose, which is the only one of the four that can cache something that is not part of a rendered response. Go as narrow as the problem allows: the wider the level, the more of the page becomes stale at once.

Key features

  • Per-site caching needs `UpdateCacheMiddleware` **first** and `FetchFromCacheMiddleware` **last** in `MIDDLEWARE` — the order is not stylistic, it is how the request wraps the response
  • Per-site and per-view caching only ever cache `GET` and `HEAD` requests that returned status 200; anything else passes straight through
  • Query strings are part of the key, so `?page=2` and `?page=3` cache separately — and `CacheMiddleware` automatically varies on the `Authorization` header
  • `@cache_page(60 * 15)` can be applied in `urls.py` instead of on the view, which keeps the view free of any knowledge that it is cached
  • `{% cache 500 sidebar request.user.username %}` — everything after the fragment name is a `vary_on` value, and each distinct combination is a separate cache entry
  • The low-level API is the only level that caches a *value* rather than rendered output: `cache.get_or_set(key, callable, timeout)` computes and stores in one call

The four levels, narrowest last

The four levels, narrowest last
LevelCachesInvalidate byReach for it when
Per-siteevery GET/HEAD 200 responsewaiting for the TTLa genuinely anonymous, mostly-static site
Per-view (`@cache_page`)one view's whole responsethe TTL, or a key-prefix bumpone expensive read-only page
Fragment (`{% cache %}`)a block of template outputthe TTL, or changing a `vary_on` valuea mostly-dynamic page with one costly region
Low-level APIany Python value`cache.delete(key)` — precisean expensive computation or upstream call
How much of the response each level owns

Per-site middleware

the entire page, for every GET/HEAD that returned 200 — the largest thing that can go stale

@cache_page on one view

one URL's full response, keyed by path and query string

{% cache %} template fragment

one block; the rest of the page still renders per request, so personalisation survives

cache.get_or_set() in Python

one value — an aggregate, an API response, a permission set. The only level with precise invalidation.

The queryset itself

no cache at all — often the right answer once select_related and an index are in place

  1. Per-site middleware — the entire page, for every GET/HEAD that returned 200 — the largest thing that can go stale
  2. @cache_page on one view — one URL's full response, keyed by path and query string
  3. {% cache %} template fragment — one block; the rest of the page still renders per request, so personalisation survives
  4. cache.get_or_set() in Python — one value — an aggregate, an API response, a permission set. The only level with precise invalidation.
  5. The queryset itself — no cache at all — often the right answer once select_related and an index are in place

Command

python
from django.core.cache import cache

value = cache.get_or_set("leaderboard:v1", compute_leaderboard, 300)

Four levels, from the whole site down to a single value, and the right one is the narrowest that removes the cost you measured. Per-site and per-view only cache `GET`/`HEAD` 200 responses and key on the URL — which makes per-site caching wrong for anything personalised. Fragment caching keeps the rest of the page live; the low-level API is the only level that caches a value rather than output, and the only one with precise invalidation. Always `list()` a queryset before caching it.

2.Redis, Memcached, cache keys, and TTL

Django ships backends for Redis (`django.core.cache.backends.redis.RedisCache`), Memcached (`PyMemcacheCache` or `PyLibMCCache`), local memory, the database, and a dummy that caches nothing. Redis and Memcached are the two real choices for a deployed application: both are shared across processes and hosts, Redis additionally persists and supports data structures, Memcached is a pure LRU cache and slightly simpler. The key you pass is not the key that is stored — Django prepends `KEY_PREFIX` and `VERSION` through `KEY_FUNCTION`, which is what lets two projects share one server and what lets you invalidate a whole class of keys by bumping a number. TTL is the `timeout` argument, where `None` means never expire and `0` means do not cache at all.

Key features

  • Backends: `redis.RedisCache`, `memcached.PyMemcacheCache`, `memcached.PyLibMCCache`, `locmem.LocMemCache`, `db.DatabaseCache`, `dummy.DummyCache`
  • `LocMemCache` is per process, so with multiple workers each holds its own copy — fine for tests, misleading in production
  • The stored key is `KEY_FUNCTION(key, KEY_PREFIX, VERSION)`, defaulting to `"%s:%s:%s"` — so the key you write is never the key on the server
  • Bumping `VERSION` orphans every old entry at once, which is the cheapest bulk invalidation there is: no scan, no delete loop, and the orphans expire by themselves
  • `timeout=None` means never expire; `timeout=0` means expire immediately, which is the supported way to say "do not cache this"
  • Memcached caps keys at 250 characters and values at 1 MB by default — a long key or a big object fails silently rather than raising

Choosing a backend

Choosing a backend
BackendShared across processes?Survives a restart?Use it for
`RedisCache`yesyes, if persistence is onthe default choice when Redis is already in the stack
`PyMemcacheCache`yesno — deliberatelya pure LRU cache you can never mistake for storage
`LocMemCache`**no** — per processnotests and local development only
`DatabaseCache`yesyeswhen you cannot run another service; it competes with your own queries
`DummyCache`——development, to prove a page is correct without caching

What `timeout` actually means

What `timeout` actually means
ValueEffect
omitteduses the backend's `TIMEOUT` option (300 seconds if unset)
a numberexpires after that many seconds
`None`never expires — survives until evicted or deleted
`0`expires immediately, i.e. do not cache
The key you write is not the key that is stored

cache.set("leaderboard:weekly", rows, timeout=300, version=3)

cache.set

writes unconditionally — Overwrites whatever was there. Use cache.add() when the point is to claim a key only if it is free — that is the lock primitive.

"leaderboard:weekly"

your part of the key — Namespace it by hand with colons. Memcached caps the FINAL key at 250 characters, and the prefix and version count toward that.

rows

the value — pickled — Must already be evaluated. A lazy QuerySet stores the query, not the rows, and re-runs the SQL on read.

timeout=300

how stale you accept — None never expires; 0 means do not cache. Omitting it falls back to the backend TIMEOUT option, 300 seconds by default.

version=3

the generation — Combined with KEY_PREFIX by KEY_FUNCTION into the real key. Bumping it orphans every earlier entry at once — bulk invalidation with no scan.

  • Whole: cache.set("leaderboard:weekly", rows, timeout=300, version=3)
  • cache.set — writes unconditionally: Overwrites whatever was there. Use cache.add() when the point is to claim a key only if it is free — that is the lock primitive.
  • "leaderboard:weekly" — your part of the key: Namespace it by hand with colons. Memcached caps the FINAL key at 250 characters, and the prefix and version count toward that.
  • rows — the value — pickled: Must already be evaluated. A lazy QuerySet stores the query, not the rows, and re-runs the SQL on read.
  • timeout=300 — how stale you accept: None never expires; 0 means do not cache. Omitting it falls back to the backend TIMEOUT option, 300 seconds by default.
  • version=3 — the generation: Combined with KEY_PREFIX by KEY_FUNCTION into the real key. Bumping it orphans every earlier entry at once — bulk invalidation with no scan.

Command

python
cache.set(key, value, timeout=300, version=None)
# stored key = KEY_FUNCTION(key, KEY_PREFIX, VERSION)  ->  "prefix:version:key"

Redis and Memcached are the two deployable backends — Redis persists and shares a server with your broker, Memcached deliberately forgets. `LocMemCache` is per process and will mislead you in production. The stored key is `KEY_PREFIX:VERSION:key`, where the prefix separates tenants and the version separates generations of your data — bumping the version is the cheapest bulk invalidation available. `timeout=None` never expires, `timeout=0` means do not cache, and give each Redis role its own database so `clear()` cannot take the queue with it.

3.Invalidation, warming, stampede, penetration, and consistency

Writing to a cache is easy; the hard parts are all about what happens next. **Invalidation** is deciding when a cached value stops being true — by TTL, by deleting the key when the source changes, or by bumping a version. **Warming** is populating a cache before traffic needs it, so a deploy does not start with a cold miss on every request. **Stampede** is what happens when a popular key expires and a thousand concurrent requests all miss it and all recompute it at once. **Penetration** is repeated misses for a key that will never exist — a lookup for an id that is not in the database — so every request reaches the database anyway. **Consistency** is the honest admission that a cache is a second copy, and two copies of anything can disagree.

Key features

  • Invalidate on write (`cache.delete(key)` in `post_save` or in the service function) rather than only relying on a short TTL — a TTL guesses, a delete knows
  • A stampede is caused by a *successful* cache: many concurrent requests queue behind one hot key and all recompute it the moment it expires
  • The standard stampede fix is a lock: `cache.add(lock_key, 1, timeout)` returns `False` if the key already exists, so exactly one request rebuilds and the rest serve stale or wait
  • Jittered TTLs (`300 + random.randint(0, 60)`) stop keys written together from expiring together — the cheapest stampede mitigation there is
  • Cache penetration is repeated misses for keys that will never exist; cache the negative result with a short TTL, or keep a bloom-filter style existence set
  • Write-then-delete leaves a window where a concurrent read repopulates the stale value — if that window is unacceptable, do not cache that field

Five failure modes, and the specific remedy for each

Five failure modes, and the specific remedy for each
ProblemWhat it looks likeRemedy
Stale dataa change is invisible for the TTLdelete the key on write, or bump `VERSION`
Cold cache after deployp99 spikes for minutes after every releasewarm the top keys from a management command
Stampededatabase CPU spikes on a regular perioda lock via `cache.add()`, plus jittered TTLs
Penetrationhigh miss rate, database load unchangedcache the "not found" answer briefly
Inconsistencytwo users see different valuesaccept it, or stop caching that field
The section's own hit/miss diagram, with the three places it goes wrong
HITMISSlock alreadyheldlockacquiredno rowrows

Request

Cache lookup

the only cheap branch in this diagram

HIT → response

possibly stale — that is the trade you accepted

MISS

now everything below runs, per request

cache.add(lock) — one winner

without this, every concurrent miss recomputes: a stampede

Losers serve stale or wait

the database sees one rebuild, not a thousand

Django → database

the expensive path the cache exists to avoid

No such row

penetration: uncached misses reach the database every time

Cache the "not found", briefly

a 30-second negative entry absorbs the flood

Write to cache with a jittered TTL

300 + random(0, 60) so keys do not expire in lockstep

Response

  • Request
    • leads to Cache lookup
  • Cache lookup — the only cheap branch in this diagram
    • leads to HIT → response (HIT)
    • leads to MISS (MISS)
  • HIT → response — possibly stale — that is the trade you accepted
  • MISS — now everything below runs, per request
    • leads to cache.add(lock) — one winner
  • cache.add(lock) — one winner — without this, every concurrent miss recomputes: a stampede
    • leads to Losers serve stale or wait (lock already held)
    • leads to Django → database (lock acquired)
  • Losers serve stale or wait — the database sees one rebuild, not a thousand
    • leads to Response
  • Django → database — the expensive path the cache exists to avoid
    • on error, leads to No such row (no row)
    • leads to Write to cache with a jittered TTL (rows)
  • No such row — penetration: uncached misses reach the database every time
    • leads to Cache the "not found", briefly
  • Cache the "not found", briefly — a 30-second negative entry absorbs the flood
    • leads to Response
  • Write to cache with a jittered TTL — 300 + random(0, 60) so keys do not expire in lockstep
    • leads to Response
  • Response

Command

python
@receiver(post_save, sender=Product)
def drop_product_cache(sender, instance, **kwargs):
    cache.delete(f"product:{instance.pk}")

All five problems are one question: how long, and to whom, may this copy be wrong? Delete on write (via `on_commit`, never inside the transaction) rather than trusting a TTL. A stampede is caused by a *successful* cache — fix it with an atomic `cache.add()` lock plus jittered TTLs. Penetration is misses for keys that will never exist — cache the "not found" with a short TTL and a sentinel, since `get()` returns `None` for both. Warm the hot keys after a deploy. And where two copies genuinely must not disagree, do not cache that field.

Remember

  • Per-site and per-view caching only cache GET/HEAD responses with status 200, and key on the URL — which makes per-site caching wrong for anything personalised.
  • Always list() a queryset before caching it: a lazy QuerySet stores the query, not the rows, and re-runs the SQL on read.
  • cache.add() is atomic and returns False when the key exists — that is the stampede lock. get() then set() lets every concurrent miss rebuild.
  • timeout=None never expires and timeout=0 means do not cache; omitting it falls back to the backend TIMEOUT option, 300 seconds by default.

Pro tip

  • Give the cache its own Redis database number: cache.clear() issues FLUSHDB, so sharing db 0 with the Celery broker means clearing the cache deletes queued tasks.
  • Jitter every TTL — 300 + random.randint(0, 60) — so keys warmed in the same loop do not all expire in the same second.

Next up

  • Redis for Django
  • Async Django
  • Background Jobs

Sources

  • Django's cache framework — docs.djangoproject.com/en/5.2/topics/cache/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.