Skip to content
>_Rong

Search the full text of every article — titles, tags, categories and article content.

Redis Caching in Django: A Practical Guide

How to add Redis caching to a Django application without shipping stale data: cache backends, key design, the cache-aside pattern, invalidation on save and stampede protection.

Rong7 min readRedis
On this page

Caching is the fastest way to make an endpoint faster and the fastest way to start serving wrong data. Both happen for the same reason: a cache is a second copy of the truth, and the hard part was never storing it.

This is how I actually add Redis caching to a Django service — what to cache, how to key it, how to invalidate it, and the failure modes that only show up under load.

Before you cache anything#

Caching is the second thing to try. Check the first thing first:

  • Is the query missing an index?
  • Is this an N+1 problem?
  • Is the endpoint serialising fields nobody reads?

A 4-second endpoint caused by an N+1 becomes a 4-second endpoint that is occasionally instant. Fix the underlying work first, then cache what is genuinely expensive and genuinely repeated.

Setting up the backend#

Django 4.0+ ships with a Redis backend, so there is no third-party cache library to install — only the Redis client.

pip install redis hiredis

hiredis is a C parser for the Redis protocol. It is optional and worth installing: it measurably reduces CPU time spent parsing responses on high-traffic services.

settings/production.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL"),
        "OPTIONS": {
            # Fail fast rather than hanging a web worker on a dead Redis.
            "socket_connect_timeout": 1,
            "socket_timeout": 1,
        },
        # Namespaced so a shared Redis, or a bad deploy, cannot collide.
        "KEY_PREFIX": "myapp",
        # Bump to invalidate every key at once, e.g. after a schema change.
        "VERSION": 1,
        "TIMEOUT": 300,
    }
}

The cache-aside pattern#

Almost all application caching is this shape: look in the cache, and on a miss compute the value and store it.

flowchart LR
    R[Request] --> C{In Redis?}
    C -->|hit| Ret[Return cached]
    C -->|miss| DB[(PostgreSQL)]
    DB --> S[Write to Redis with TTL]
    S --> Ret

Django's get_or_set does exactly this:

services/stats.py
from django.core.cache import cache
 
def get_author_stats(author_id: int) -> dict:
    return cache.get_or_set(
        f"author:{author_id}:stats:v1",
        lambda: _compute_author_stats(author_id),
        timeout=300,
    )

Two things about that key are deliberate.

It is structured. author:{id}:stats reads like a path, sorts sensibly in redis-cli --scan, and tells you what it is at 3 a.m.

It is versioned. When the shape of _compute_author_stats changes, bump v1 to v2. Deploying new code that reads an old cached shape is one of the more unpleasant ways to break production, and a version suffix makes it a non-event — old keys simply expire unread.

Invalidation#

There are only two strategies worth using, and you should be explicit about which one each cache entry is on.

TTL only#

Let the entry expire. Accept staleness up to the TTL. This is correct for anything where "a minute old" is fine, and it is by far the more robust option because it has no code path that can be forgotten.

Explicit invalidation on write#

When the data must be fresh immediately after a change, delete the key when the underlying model changes. Signals are the reliable place to do that, because they fire regardless of which code path saved the object:

signals.py
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
 
from . import cache_keys
from .models import Article
 
@receiver([post_save, post_delete], sender=Article)
def invalidate_author_stats(instance: Article, **kwargs) -> None:
    cache.delete(cache_keys.author_stats(instance.author_id))

Invalidate after the transaction commits#

A subtle and genuinely painful bug: if you delete the cache key inside a transaction that later rolls back — or that has not committed yet — a concurrent request can repopulate the cache from the old database state, and the stale value then survives until its TTL.

signals.py
from django.db import transaction
 
@receiver(post_save, sender=Article)
def invalidate_author_stats(instance: Article, **kwargs) -> None:
    # Only run once the data is actually visible to other connections.
    transaction.on_commit(
        lambda: cache.delete(cache_keys.author_stats(instance.author_id))
    )

transaction.on_commit is the correct default for every cache invalidation that lives in a signal.

Caching querysets#

Django querysets are lazy, so caching one caches nothing useful — the pickled queryset still hits the database when evaluated. Force evaluation first:

# Wrong: caches an unevaluated queryset.
cache.set(key, Article.objects.filter(published=True))
 
# Right: caches the rows.
cache.set(key, list(Article.objects.filter(published=True)))

For a list endpoint, caching the serialised output is usually better than caching model instances — it skips serialisation on a hit, which is often a meaningful share of the response time.

api/views.py
class ArticleListView(APIView):
    def get(self, request):
        key = cache_keys.article_list(page=request.query_params.get("page", 1))
        payload = cache.get(key)
 
        if payload is None:
            queryset = Article.objects.published().select_related("author")
            payload = ArticleSerializer(queryset, many=True).data
            cache.set(key, payload, timeout=120)
 
        return Response(payload)

The failure modes that only appear under load#

Cache stampede#

A popular key expires. Two hundred concurrent requests miss simultaneously, and all two hundred run the expensive query at once — against a database that was comfortable a second ago.

sequenceDiagram
    participant R as 200 requests
    participant C as Redis
    participant DB as PostgreSQL
    Note over C: key expires
    R->>C: GET stats (x200)
    C-->>R: nil (x200)
    R->>DB: expensive query (x200)
    Note over DB: CPU saturated

The standard fix is a short lock: the first request to miss takes the lock and recomputes; everyone else waits briefly and reads the fresh value.

services/cache.py
import time
from typing import Callable, TypeVar
 
from django.core.cache import cache
 
T = TypeVar("T")
 
def get_or_set_locked(
    key: str,
    compute: Callable[[], T],
    timeout: int,
    lock_timeout: int = 10,
) -> T:
    """Cache-aside with single-flight recomputation.
 
    Only one caller recomputes a missing key; the rest poll briefly for the
    result and fall back to computing it themselves if the holder dies.
    """
    value = cache.get(key)
    if value is not None:
        return value
 
    lock_key = f"{key}:lock"
    # `add` is atomic in Redis (SET NX) — exactly one caller wins.
    if cache.add(lock_key, "1", timeout=lock_timeout):
        try:
            value = compute()
            cache.set(key, value, timeout=timeout)
            return value
        finally:
            cache.delete(lock_key)
 
    # Lost the race: wait for the winner rather than duplicating its work.
    for _ in range(lock_timeout * 10):
        time.sleep(0.1)
        value = cache.get(key)
        if value is not None:
            return value
 
    # The lock holder died. Compute rather than fail.
    return compute()

Redis being down#

Django's Redis backend raises on connection failure. Without handling, a Redis outage becomes a site outage — even though every cached value could have been recomputed from PostgreSQL.

services/cache.py
import logging
from redis.exceptions import RedisError
 
logger = logging.getLogger(__name__)
 
def cache_get(key: str, default=None):
    """Read from cache, degrading to a miss if Redis is unavailable."""
    try:
        return cache.get(key, default)
    except RedisError:
        logger.warning("cache unavailable", extra={"key": key}, exc_info=True)
        return default

Degrading to a miss is almost always right: slower, but correct.

Caching for the wrong scope#

Per-user data cached under a global key leaks one user's data to another. This is a security bug, not a performance bug. If a cached value depends on the request user, permissions or tenant, that must be in the key:

def dashboard(user_id: int, tenant_id: int) -> str:
    return f"tenant:{tenant_id}:user:{user_id}:dashboard:v3"

Measuring whether it worked#

A cache you have not measured is a guess. Two numbers matter.

Hit rate, from Redis itself:

redis-cli info stats | grep keyspace
# keyspace_hits:184203
# keyspace_misses:9117

That is a 95% hit rate. Below roughly 80%, the TTL is too short or the key is too specific, and you are paying the complexity cost of a cache without getting the benefit.

Endpoint latency, before and after. For the dashboard aggregate that prompted this article:

Configurationp50p95DB CPU
No cache840 ms2,100 ms62%
60 s TTL12 ms890 ms11%
60 s TTL + single-flight12 ms210 ms9%

The p50 improvement came from the cache. The p95 improvement came from the stampede lock — before it, every cache expiry produced a burst of slow requests, and those bursts were the entire tail.

What I would tell myself before starting#

  1. Fix the underlying query first. Caching is not a substitute for an index.
  2. Version every key. It makes shape changes free.
  3. Build keys in one module. Read and invalidate paths must agree.
  4. Invalidate in transaction.on_commit, never inline.
  5. Set socket timeouts and degrade to a miss. Redis will be down eventually.
  6. Anything that depends on the user goes in the key.
  7. Measure the hit rate. If it is low, delete the cache rather than tuning it.

Discussion

Loading the discussion…