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.

Why a Django cache never hitsSettingswhich backend?Processshared or per-workerResponsecacheable at all?Keysame every time?Check each layer in order; most silent misses are a backend or Vary problem.
  1. Print settings.CACHES in a production shell and confirm it is Redis or Memcached, not LocMemCache or DummyCache.
  2. Test set and get across processes: set from one shell, read from another.
  3. For cache_page and the per-site middleware, remember responses with Vary: Cookie (any session or CSRF use) are cached per user, effectively never shared.
  4. Check that keys are stable: no timestamps, request objects, or random values in cache keys.
  5. Look for swallowed connection errors with DJANGO_REDIS_IGNORE_EXCEPTIONS and 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.

four Gunicorn workers, one cache keyLocMemCache✕each worker has its own copy✕miss until every worker warms up✕invalidation reaches one worker onlyRedis or Memcached✓one copy shared by all workers✓survives worker restarts✓delete() clears it everywhereLocal memory caches also make invalidation inconsistent between workersdjango-deployment.com
LocMemCache is fine for development and tests, not for multi-process production.

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.session or request.user, which adds Vary: 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: private or no-cache.
  • The request is not GET or HEAD, 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.

work through these in orderProduction CACHES is Redis or Memcachednot LocMem or DummySet in one shell, get in anotherproves the cache is sharedCached views avoid Vary: Cookieno session, user, or CSRFKeys contain no timestamps or objectsstable between requestsIgnored exceptions are loggedmisses are not hidden errorsMeasure the hit rate from keyspace_hits and keyspace_misses afterwardsdjango-deployment.com
Each check rules out one of the silent ways a cache turns into a miss.

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: 0 means "expire immediately"; None means "never expire". A setting of 0 disables 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 maxmemory with an eviction policy and too little memory, keys are evicted quickly. Check evicted_keys in INFO stats.
  • CDN in front. If you expected Cloudflare or Nginx to cache, those layers read Cache-Control, not Django's cache.

Next steps

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.

Keep going