Python Redis Integration Patterns
This page maps how the major Python web and task frameworks wire Redis in as a cache, and pins down exactly where the invalidation hook belongs in each so a write in the source of truth is never left serving a stale key.
Every Python stack eventually reaches for Redis, but the shape of that integration differs sharply between a synchronous Django process, an asynchronous FastAPI service, and a Celery worker pool. The differences are not cosmetic: they decide when the connection pool is built, whether the client is safe to share across a fork(), and — most importantly — which lifecycle event you can hang an invalidation on. Get the wiring right and the caching mechanics from Redis Caching Architecture & Invalidation Fundamentals apply cleanly on top; get it wrong and you inherit pool churn, cross-talk between forked workers, and mutations that silently skip the cache. This page treats the framework integration itself as the object of study, and defers the deep configuration of each backend to its own guide.
Architectural Trade-offs
The three frameworks below cover the overwhelming majority of Python Redis deployments, and each imposes a different answer to three questions: when does the connection pool come into existence, does the client speak the blocking or the asyncio protocol, and what lifecycle event is the natural home for a cache bust. Read the table as a decision surface — the row that matches your runtime dictates where your invalidation code has to live.
| Framework | Connection lifecycle | Sync/async | Invalidation hook |
|---|---|---|---|
Django (django-redis) |
One process-wide ConnectionPool built lazily on first cache access, reused for every request |
Synchronous (blocking redis-py) |
cache.delete fired from a post_save/post_delete model signal, ideally inside transaction.on_commit |
FastAPI (redis.asyncio) |
One pool created in the ASGI lifespan startup, held on app.state, closed on shutdown |
Asynchronous (await-based redis.asyncio) |
await client.unlink(...) in the write handler, after the database commit |
| Celery (result backend) | One client per worker process, established after the fork in the worker bootstrap | Synchronous inside the task body | Explicit purge as a task step, or a dedicated invalidation task enqueued from the producer |
The column that trips teams up is the connection lifecycle. A cache client is expensive to build and cheap to reuse, so it must outlive the request — but in a forking server "outlive the request" quietly becomes "outlive the fork," and a pool inherited across fork() shares file descriptors that were never meant to be shared. The last column is where correctness is won or lost: a framework can cache perfectly and still serve stale data forever if the write path has no place to hang the bust. Both Django's synchronous model and FastAPI's async model implement the same cache-aside pattern underneath — the application reads Redis, falls through to the database on a miss, and repopulates — so the interesting engineering is entirely in the lifecycle and the hook.
Approach A — Synchronous framework caches (Django django-redis)
Django's cache framework is synchronous and process-global by design. The django-redis backend registers a single ConnectionPool per process the first time any code touches cache, and every subsequent request borrows a connection from that pool rather than dialing Redis afresh. This is the correct default for the classic WSGI deployment (gunicorn or uWSGI with pre-forked workers), where each worker is a long-lived process handling requests serially. You configure it declaratively in CACHES, and the pool sizing, timeouts, and a default TTL all live in that one block:
# settings.py — django-redis registers Redis as the default cache backend.
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://redis-primary:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
# One process-wide pool, reused across every request this worker serves.
"CONNECTION_POOL_KWARGS": {"max_connections": 50},
"SOCKET_CONNECT_TIMEOUT": 2, # fail fast instead of hanging a worker
"SOCKET_TIMEOUT": 2,
},
"KEY_PREFIX": "app",
"TIMEOUT": 300, # default TTL in seconds — the safety net behind explicit busts
}
}
With the backend registered you have two ways to read and write: the per-view cache (the @cache_page decorator, which stores a whole rendered response) and the low-level cache API (cache.get / cache.set), which gives you per-key control over exactly what is stored and when it is purged. For anything that has to be invalidated on a specific mutation, the low-level API is the only viable choice — a whole-page cache is far too coarse to bust precisely. The read path is ordinary cache-aside; the write path is where the invalidation hook attaches:
from django.core.cache import cache
from django.db import transaction
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Product
def product_key(product_id: int) -> str:
return f"product:{product_id}"
def get_product(product_id: int) -> dict:
key = product_key(product_id)
cached = cache.get(key) # low-level cache API read
if cached is not None:
return cached # cache hit
product = Product.objects.filter(pk=product_id).values().first()
cache.set(key, product, timeout=300) # cache-aside populate, with a TTL
return product
@receiver(post_save, sender=Product)
@receiver(post_delete, sender=Product)
def invalidate_product(sender, instance, **kwargs):
# The invalidation hook lives on the model signal. Defer the delete until
# the surrounding transaction actually commits, so a rolled-back write
# never busts a key that still holds valid data.
transaction.on_commit(lambda: cache.delete(product_key(instance.pk)))
The transaction.on_commit wrapper is the detail that separates a correct Django cache from a subtly broken one. A raw post_save receiver fires while the transaction is still open; if that transaction later rolls back, you have already deleted a cache entry that the database still considers valid, and — worse — a concurrent reader can repopulate the key from the pre-rollback state. Deferring the bust to commit time closes that race. Note also the deliberate choice of cache.delete over rewriting the key: deleting lets the next reader repopulate from the freshly committed row, which is simpler and less race-prone than trying to write the new value into the cache from inside the signal. The one blind spot of the signal approach is bulk operations, which the failure-modes section returns to.
Approach B — Async ASGI integration (FastAPI + redis.asyncio)
FastAPI runs on an ASGI event loop, so a blocking redis-py call would stall every coroutine sharing that loop. The correct client is redis.asyncio, and the correct lifecycle is the ASGI lifespan context: you build exactly one connection pool when the application starts, stash the client on app.state, and tear both down on shutdown. Because a single Uvicorn worker drives many concurrent requests on one loop, that one pool is shared safely across all of them — there is no per-request client, and there must not be. The lifespan handler is the canonical place for this, and the dependency-injection system hands the shared client to each route:
import contextlib
import redis.asyncio as redis
from fastapi import Depends, FastAPI
REDIS_URL = "redis://redis-primary:6379/0"
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
# Build ONE pool at startup; every request on this worker borrows from it.
pool = redis.ConnectionPool.from_url(
REDIS_URL, max_connections=50, decode_responses=True
)
app.state.redis = redis.Redis(connection_pool=pool)
try:
yield
finally:
await app.state.redis.aclose() # graceful client shutdown
await pool.disconnect() # release every socket in the pool
app = FastAPI(lifespan=lifespan)
async def get_redis() -> redis.Redis:
# Inject the shared client; do NOT construct a new Redis() per request.
return app.state.redis
With the client injected, the read handler is cache-aside and the write handler carries the invalidation hook. The hook is a single await client.unlink(...) placed after the database commit — the async analogue of Django's on_commit deferral, made explicit by control flow rather than a callback:
import json
@app.get("/products/{product_id}")
async def read_product(product_id: int, r: redis.Redis = Depends(get_redis)):
key = f"product:{product_id}"
cached = await r.get(key)
if cached is not None:
return json.loads(cached) # cache hit
product = await load_from_db(product_id)
await r.set(key, json.dumps(product), ex=300) # populate + safety-net TTL
return product
@app.put("/products/{product_id}")
async def update_product(
product_id: int, body: dict, r: redis.Redis = Depends(get_redis)
):
await save_to_db(product_id, body) # commit the source of truth first
# Invalidation hook: UNLINK reclaims memory off the event loop, so a large
# value never blocks the other coroutines sharing this worker's loop.
await r.unlink(f"product:{product_id}")
return {"status": "ok"}
Two async-specific rules matter here. First, always UNLINK rather than DEL, because on a single-threaded event loop the synchronous memory reclamation that DEL performs can stall every other in-flight request; UNLINK detaches the key immediately and frees the memory on a background thread. Second, order the write path so the database commit precedes the bust, never the reverse — busting first opens a window in which a concurrent reader repopulates the cache from the old row before your commit lands. When the invalidation has to fan out to other services rather than purge a local key, the write handler should publish onto a durable channel instead of firing and forgetting, a pattern covered in async invalidation workflows. The full mechanics of building and tuning the pool this handler depends on live in the dedicated FastAPI lifespan guide linked at the foot of this page.
Task Workers — Celery as a Redis Result Backend
Celery is the odd one out because it is neither request-scoped nor loop-scoped: it is a pool of forked worker processes, and Redis commonly serves it twice over — once as the broker that carries task messages and once as the result backend that stores each task's return value and state. The result backend is a cache in everything but name, and it has the same unbounded-growth hazard as any cache: without an expiry, every completed task's result accumulates in Redis forever. The fix is result_expires, which puts a TTL on stored results so the backend self-trims:
# celery_app.py — Redis as broker and as the result backend.
from celery import Celery
app = Celery(
"tasks",
broker="redis://redis-broker:6379/2", # task queue
backend="redis://redis-primary:6379/3", # task results are cached here
)
# Expire stored results so the backend does not grow without bound.
app.conf.result_expires = 3600 # one hour, in seconds
The connection lifecycle for a Celery worker is fork-aware: the worker master process forks children, and each child must own its Redis client. Celery's own broker and backend connections handle this correctly, but any application cache client you use inside a task — to purge keys as part of the work — has to be created after the fork, not inherited from the master. That constraint is the same one the failure-modes section formalizes for every forking server. Because Celery is the natural place to offload heavy or high-fan-out invalidation off the request path, it slots directly into the async invalidation workflows discussed above. The full setup — serializer choice, ignoring results you never read, and backend sizing — is covered in the Celery result-backend guide linked below.
When to Choose Which
The integration you reach for is decided by two axes: whether your framework runtime is synchronous or asynchronous, and whether Redis connections should be scoped per request or held for the lifetime of the process. The diagram encodes the primary branch; the numbered criteria refine it against concrete production signals.
- Synchronous vs asynchronous stack. If your framework runs on WSGI and blocking views — classic Django, Flask — use the synchronous
redis-pyclient and let the framework's process-global cache hold the pool. If it runs on an ASGI event loop — FastAPI, Starlette, async Django views — useredis.asyncioand build the pool inlifespan; mixing a blocking client into an event loop is the single most common async performance regression. - Per-request vs app-lifetime connections. Connections must be scoped to the process, never the request. A per-request
Redis()construction pays a TCP and (often) TLS handshake on every call and defeats pooling entirely. Build the pool once — inCACHESfor Django, inlifespanfor FastAPI, in the post-fork worker bootstrap for Celery — and reuse it for the life of the process. - Forking model. Any pre-forking server (gunicorn, uWSGI, the Celery worker pool) must not let a client built in the master leak into the children. Either construct the client lazily on first use inside each worker, or rebuild it in a post-fork hook. The connection lifecycle you pick has to survive
fork(), which is why the deeper trade-offs live in connection pooling. - Where the write path can reach. Choose the hook that the framework actually lets you attach to the commit. Django gives you model signals plus
transaction.on_commit; FastAPI gives you explicit control flow in the handler; Celery gives you a task step. If a write path — a bulk ORM update, a raw SQL migration — bypasses that hook, it will bypass invalidation too, and you need a compensating TTL or an explicit purge.
For most services the pragmatic answer is: pick the client that matches your loop model, hold the pool for the process lifetime, and put the bust on whichever commit-time event your framework exposes — always backed by a conservative TTL so a missed hook self-heals.
Failure Modes and Diagnostics
Three failure modes account for nearly every Python-integration incident. Each has a fast diagnosis before you reach for a fix.
Connection created per request (pool churn). A client constructed inside the view or handler — rather than once per process — opens a fresh connection on every request and discards it, so connected_clients climbs in lockstep with request rate and Redis spends its time on handshakes instead of commands. Diagnose by watching the client count under steady load: a healthy pooled integration plateaus near max_connections, while churn climbs without bound.
# Under steady traffic, connected_clients should plateau, not track RPS.
redis-cli INFO clients | grep -E "connected_clients|blocked_clients"
redis-cli CLIENT LIST | wc -l # a number that grows with load == per-request clients
The fix is to move client construction out of the request path and into the process-scoped location for your framework — CACHES, lifespan, or the worker bootstrap.
Forking workers sharing a client. A client built in the parent process before a pre-fork server forks its workers is inherited by every child, so multiple worker processes share the same underlying socket file descriptors. The result is corrupted responses and cryptic protocol errors as two workers interleave reads and writes on one connection. Diagnose by correlating the errors with worker startup and confirming the client was created before the fork:
# Errors that appear only under multiple workers, never with --workers 1,
# point at a shared pre-fork client. Watch for protocol desync in the log:
redis-cli CLIENT LIST | awk '{print $2}' | sort | uniq -c # addr reuse across PIDs
The fix is to defer client creation until after the fork: lazy initialization on first use inside each worker, a gunicorn post_fork hook, or — for FastAPI under Uvicorn/Gunicorn workers — the lifespan handler, which runs per worker.
Missing invalidation on bulk updates. Django's QuerySet.update() and bulk_update() write straight to SQL and never emit post_save, so a signal-based invalidation hook simply does not fire; the same gap appears any time a raw query or a data migration mutates rows behind the ORM's back. The cache then serves the pre-update value until its TTL expires. Diagnose by reconciling a sampled key against the source of truth after a known bulk write:
# After a bulk update, the cached value should be gone or refreshed.
redis-cli GET product:1001 # stale payload still present == missed bust
redis-cli TTL product:1001 # confirm a TTL exists as the safety net
The fix is to invalidate explicitly after any bulk path — enumerate the affected keys and UNLINK them in bounded batches with SCAN, never KEYS — and to keep a conservative TTL on every key so an overlooked write path self-heals within a bounded window rather than serving stale data indefinitely.
Verification
Confirm two properties against a live instance before trusting the integration under load: that the process reuses a single pool, and that a repeated read is served from cache.
First, verify the pool is shared and stable. Drive the endpoint with a burst of concurrent requests and watch that the client count settles near max_connections instead of climbing with the request total — the signature of one reused pool rather than a client per request:
# Baseline, then hammer the endpoint, then re-check — the delta should be small.
redis-cli INFO clients | grep connected_clients
# ... send a few thousand requests at your service ...
redis-cli INFO clients | grep connected_clients # plateaued near max_connections
Second, verify the cache actually serves the second request. Request the same key twice and confirm keyspace_hits advances while keyspace_misses stays flat — proof the read path repopulated on the miss and returned from Redis on the repeat:
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# first request -> misses +1 (populate); second request -> hits +1 (served from cache)
Finally, confirm the invalidation hook fires on write. Read a key to populate it, issue the mutation through your framework, and check the key is gone — the next read must miss and repopulate from the freshly committed row:
redis-cli EXISTS product:1001 # 1 after a read populates it
# ... issue the PUT / save / task that mutates product 1001 ...
redis-cli EXISTS product:1001 # expect 0 — the hook busted the key
A green result on all three — a plateaued pool, a hit on the second read, and a purged key after a write — means the framework is wired to Redis correctly and the invalidation hook is in the right place. Track connected-client counts and hit ratio as first-class metrics so a future refactor that reintroduces per-request clients or bypasses the hook surfaces on a dashboard before it surfaces in an incident.
Up: Redis Caching Architecture & Invalidation Fundamentals
Related
- Configuring the Django Redis Cache Backend — the full
CACHESsetup, client class, and pool tuning fordjango-redis. - Managing Redis Connections with the FastAPI Lifespan — building and tearing down the async pool in the ASGI lifespan.
- Using Redis as a Celery Result Backend — result storage, expiry, and fork-safe worker connections.
- Cache-Aside vs Read-Through Patterns — the read/populate contract every framework integration implements underneath.