Fix Django Cache Not Working in Production
Fix a Django cache that never hits in production: confirm the configured backend, avoid LocMemCache across processes, understand why Vary Cookie and CSRF break per-view caching, and check keys, versions, and timeouts.
Problem statement
You added caching, but production is as slow as before. Logs show the expensive query running on every request, Redis shows almost no keys, or cache.get() returns None right after cache.set(). Locally it seemed to work.
Django's cache can fail quietly in several ways: the production settings use a different backend than you think, each Gunicorn worker has its own private memory cache, per-view caching skips every response that varies by cookie, or a backend connection error is swallowed and treated as a miss. None of these raise exceptions by default, so the cache simply never hits.
- Print
settings.CACHESin a production shell and confirm it is Redis or Memcached, notLocMemCacheorDummyCache. - Test set and get across processes: set from one shell, read from another.
- For
cache_pageand the per-site middleware, remember responses withVary: Cookie(any session or CSRF use) are cached per user, effectively never shared. - Check that keys are stable: no timestamps, request objects, or random values in cache keys.
- Look for swallowed connection errors with
DJANGO_REDIS_IGNORE_EXCEPTIONSand logging.
Step-by-step solution
1. Confirm the backend in production
python manage.py shell -c "from django.conf import settings; print(settings.CACHES)"
If you see django.core.cache.backends.locmem.LocMemCache (the default when CACHES is not set) or DummyCache, the production settings module does not define your Redis cache. Check which settings module the server loads and that the environment variable with the Redis URL is present.
2. Test across processes
python manage.py shell -c "from django.core.cache import cache; cache.set('probe', 'ok', 300)"
python manage.py shell -c "from django.core.cache import cache; print(cache.get('probe'))"
Two separate shells are two processes. With LocMemCache the second prints None, which is exactly what happens between Gunicorn workers: each has its own cache, so a request served by worker 3 cannot see what worker 1 stored. Hit rates drop as worker count rises.
3. Understand why cache_page skips responses
The per-view and per-site caches only store responses that are safe to share. They skip or fragment responses when:
- The view touches
request.sessionorrequest.user, which addsVary: Cookie. The cache key then includes the cookie value, so every user gets their own entry. - A template renders
{% csrf_token %}, which also varies by cookie. - The response sets cookies or has
Cache-Control: privateorno-cache. - The request is not
GETorHEAD, or the status is not 200.
Inspect the headers:
curl -sI https://example.com/pricing/ | grep -iE "vary|cache-control|set-cookie"
For pages that need per-user parts, cache the expensive fragments instead of the whole response: {% cache 600 sidebar %} or the low-level API around the query.
4. Make keys stable
key = f"report:{request.user.id}:{timezone.now()}" # never hits
key = f"report:{request.user.id}:{date.today()}" # hits all day
Watch for objects whose str() includes memory addresses, unsorted dicts used in keys, and query parameters in different orders. Also check KEY_PREFIX and VERSION: if web and worker processes use different settings, they write and read different namespaces.
5. Surface swallowed errors
django-redis with DJANGO_REDIS_IGNORE_EXCEPTIONS = True turns connection failures into misses. Log them:
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
LOGGING["loggers"]["django_redis"] = {"handlers": ["console"], "level": "WARNING"}
Then check redis-cli INFO stats for keyspace_hits and keyspace_misses to compute a real hit rate.
Verification check: after a deploy, request a cached page twice and time both, or watch keyspace_hits rise in redis-cli INFO stats. A healthy cache shows a hit rate well above 80 percent for content that is meant to be cached.
Explanation
Django's cache framework is designed to degrade gracefully: a miss just means doing the work. That is good for availability but means misconfiguration shows up only as slowness. The per-site and per-view caches are also conservative on purpose; caching a page that included one user's session data and serving it to another user would be a security bug, so anything that varies by cookie is either skipped or keyed per user. Explicit low-level caching of data, not responses, is usually the more effective tool for authenticated pages.
Edge cases / notes
- Timeouts.
TIMEOUT: 0means "expire immediately";Nonemeans "never expire". A setting of0disables caching without errors. - Pickling. Objects that cannot be pickled raise errors on
set; lazy querysets are evaluated when pickled, which can be surprisingly expensive. - Eviction. If Redis has
maxmemorywith an eviction policy and too little memory, keys are evicted quickly. Checkevicted_keysinINFO stats. - CDN in front. If you expected Cloudflare or Nginx to cache, those layers read
Cache-Control, not Django's cache.
Next steps
- Decide what to cache with Django Caching Strategy for Production.
- Configure the backend with Set Up a Redis Cache for Django in Production.
FAQ
Why does caching work with runserver but not Gunicorn?
runserver is one process, so LocMemCache looks shared. Gunicorn runs several processes, each with its own private cache.
Why does cache_page not cache logged-in pages?
Using the session or CSRF token adds Vary: Cookie, so the response is cached per cookie value, not shared.
How do I see the cache hit rate?
For Redis, compare keyspace_hits and keyspace_misses in INFO stats.
Does DEBUG affect caching?
Not directly, but debug-only settings modules often use LocMemCache or DummyCache.