Configuring the Django Redis Cache Backend
Your Django application is hammering PostgreSQL with the same read queries on every request, and you want Redis to absorb them through Django's built-in cache framework rather than a hand-rolled client scattered across views. The scenario is specific: you have a running Redis 7.x instance, a Django 4.2 or 5.x project, and you need the django.core.cache API — cache.get, cache.set, the @cache_page decorator, and the low-level cache — to route through Redis with a sensible connection pool, a namespaced key prefix, and invalidation that fires the instant a model changes. This walkthrough configures the django-redis backend end to end and then proves each piece works against a live server.
Prerequisites
- Redis 6.2+ or 7.x, reachable from every Django process (web workers and any background workers).
- Django 4.2 LTS or 5.x on Python 3.10+.
pip install "django-redis>=5.4" "redis>=5,<6"—django-rediswrapsredis-py5.x as its client.- Write access to
settings.pyand permission to runredis-cliagainst the same instance for verification. - A rough idea of your peak concurrent request count per process, which sets the connection pool ceiling.
Step-by-Step Implementation
1. Install django-redis and confirm the client
django-redis is the cache backend; it depends on redis-py, which does the actual talking. Because Django's cache framework is synchronous request-path infrastructure, this is one of the sync-only cases — the backend uses the blocking redis-py client, not redis.asyncio.
pip install "django-redis>=5.4" "redis>=5,<6"
python -c "import django_redis, redis; print(django_redis.__version__, redis.__version__)"
2. Point the CACHES setting at RedisCache
Register django_redis.cache.RedisCache as the default backend. The LOCATION is a standard Redis URL; db index 1 keeps cache data off db 0 so a stray FLUSHDB during debugging cannot wipe another dataset.
# settings.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
3. Tune the connection pool
Every Django process opens its own pool. Size max_connections against the process's peak concurrency — a synchronous WSGI worker rarely needs more than a handful — and set explicit socket timeouts so a stalled Redis never wedges a request thread indefinitely. This mirrors the pool-tuning discipline covered in Connection Pooling and Client Resilience.
# settings.py — extend OPTIONS on the default cache
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {
"max_connections": 50, # ceiling per Django process
"retry_on_timeout": True,
},
"SOCKET_CONNECT_TIMEOUT": 2, # seconds to establish a TCP connection
"SOCKET_TIMEOUT": 2, # seconds to wait on a command
},
4. Namespace with KEY_PREFIX and versioning
KEY_PREFIX prepends an application namespace to every key so multiple services sharing one Redis instance never collide. VERSION lets you invalidate an entire generation of keys by bumping a single integer — the old version's keys simply stop being read and age out. Django composes the stored key as prefix:version:key.
# settings.py — namespacing applies to the whole default cache
CACHES["default"]["KEY_PREFIX"] = "shop"
CACHES["default"]["VERSION"] = 3 # bump to retire a whole key generation
CACHES["default"]["TIMEOUT"] = 300 # default TTL in seconds for cache.set
5. Cache whole responses with cache_page
For read-heavy views whose output changes slowly, @cache_page stores the rendered HttpResponse under a key derived from the URL. This is a read-through-style shortcut at the view layer; the deeper trade-offs live in Cache-Aside vs Read-Through Patterns.
from django.views.decorators.cache import cache_page
@cache_page(60 * 15) # cache the rendered response for 15 minutes
def product_listing(request):
products = Product.objects.filter(active=True)
return render(request, "catalog/list.html", {"products": products})
6. Use the low-level cache for fine-grained control
When you need to cache a single expensive object rather than a whole page, reach for the low-level API. cache.get/set/delete map directly onto Redis GET, SET ... EX, and UNLINK under django-redis. Setting an explicit timeout keeps every entry bounded even if your default policy changes — the same safety-net thinking as TTL vs Explicit Invalidation.
from django.core.cache import cache
def get_product(product_id: int) -> dict:
key = f"product:{product_id}"
payload = cache.get(key)
if payload is None: # miss: load and populate
payload = Product.objects.values().get(pk=product_id)
cache.set(key, payload, timeout=300) # bounded 5-minute entry
return payload
7. Invalidate on the post_save signal
To purge a cached object the moment its row changes, hang a post_save (and post_delete) receiver off the model. The signal fires inside the same process that committed the write, so the delete reaches Redis before the next read can serve a stale copy. For high-volume or cross-service fan-out, hand the purge to async invalidation queues with Celery instead of deleting inline.
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
@receiver([post_save, post_delete], sender=Product)
def bust_product_cache(sender, instance, **kwargs):
# cache.delete issues UNLINK under django-redis, reclaiming memory off-thread.
cache.delete(f"product:{instance.pk}")
Failure Modes
All requests share a stale generation after a schema change. You changed the shape of a cached object but old-format entries keep getting served, breaking template rendering. Diagnose by inspecting a live key and confirming its version segment:
redis-cli -n 1 --scan --pattern "shop:*" | head
redis-cli -n 1 GET "shop:2:product:42" # note the ':2:' version generation
Fix by bumping VERSION in settings.py (Step 4). Every read now composes shop:3:..., so the incompatible :2: keys are never read again and expire on their TTL — an instant, atomic generation cut with no FLUSHDB.
Connection pool exhaustion under a traffic spike. Requests hang and then raise redis.exceptions.ConnectionError: Too many connections when concurrency exceeds max_connections. Diagnose by watching the live client count against your per-process ceiling:
redis-cli -n 1 INFO clients | grep connected_clients
Fix by raising max_connections to cover workers × threads × peak-in-flight, or by adding worker processes so each keeps a smaller pool. Keep SOCKET_TIMEOUT set so a slow command frees its connection instead of pinning it.
A missed post_save leaves a stale object. A bulk update via QuerySet.update() bypasses save(), so post_save never fires and the cached copy survives the change. Diagnose by reconciling the row's updated timestamp against the cached payload:
redis-cli -n 1 GET "shop:3:product:42" # compare against the DB's updated_at
Fix by invalidating explicitly after bulk operations (call cache.delete_many for the affected ids) and keeping a conservative TIMEOUT so any purge you miss self-heals within the window.
Verification
Confirm Django is actually writing to Redis and that keys carry your namespace and a TTL:
python -c "import django; django.setup(); from django.core.cache import cache; cache.set('probe', 'ok', 30); print(cache.get('probe'))"
redis-cli -n 1 --scan --pattern "shop:*:probe" # expect the namespaced key
redis-cli -n 1 TTL "shop:3:probe" # expect a positive integer, never -1
Confirm invalidation removes the key rather than rewriting it:
redis-cli -n 1 EXISTS "shop:3:product:42" # expect 1 while cached
# ... trigger a save on Product #42 ...
redis-cli -n 1 EXISTS "shop:3:product:42" # expect 0 after post_save fires
FAQ
Can I store Django sessions in the same Redis cache?
Yes. Set SESSION_ENGINE = "django.contrib.sessions.backends.cache" and SESSION_CACHE_ALIAS = "default", or use the cached-database backend for durability. In production most teams put sessions in a separate cache alias pointing at a different db index or instance, so a cache flush of page fragments never logs every user out. Give the session cache a longer TIMEOUT than your page cache, since session lifetime and fragment freshness are unrelated concerns.
What is the difference between KEY_PREFIX and VERSION?
KEY_PREFIX is a static namespace that separates applications sharing one Redis instance — it never changes at runtime. VERSION is a generation counter you bump to retire a whole class of cached values at once, typically when the serialized shape of your objects changes. Django stores both in the composite key prefix:version:key, so raising VERSION makes every existing key unreachable without deleting anything; the orphaned entries expire on their own TTL.
How should I size the connection pool?
Start from the process model. A synchronous WSGI worker handles one request at a time per thread, so max_connections only needs to cover the threads in that process plus a little headroom, not your whole fleet. Multiply threads × concurrent-cache-calls-per-request and add a margin. Oversizing wastes file descriptors on Redis and hides leaks; undersizing throws ConnectionError under load. Measure connected_clients during a load test and tune from the real number.
Do I need CONN_MAX_AGE or persistent connections for the cache?
CONN_MAX_AGE is a database setting and has no effect on the cache. django-redis already maintains a persistent connection pool that survives across requests, so cache connections are reused by default — there is nothing extra to enable. What you should tune instead is SOCKET_CONNECT_TIMEOUT and SOCKET_TIMEOUT so a network stall fails fast, plus retry_on_timeout so a transient blip retries once rather than surfacing as a 500.
Up: Python Redis Integration Patterns