Django Caching Strategy for Production

Choose what to cache in a Django app: HTTP caching, per-view and fragment caches, low-level caching, invalidation, stampede protection, and hit rates.

Problem statement

Caching is one of the most effective ways to make a Django site faster and cheaper to run, and one of the easiest ways to introduce bugs that are hard to reproduce: users seeing each other's data, prices that do not update, admin changes that "don't save", or a cache expiry that suddenly sends thousands of expensive queries to the database at once.

Django offers several caching layers, and they solve different problems:

  • HTTP caching headers that let browsers and CDNs reuse responses
  • whole-view caching with cache_page or the per-site cache middleware
  • template fragment caching with {% cache %}
  • the low-level cache API for computed values
  • database-level improvements that make caching unnecessary in the first place

A production caching strategy decides which layer to use for which data, how entries are invalidated, and how you know the cache helps. This guide builds that decision process.

Caching layersBrowser or CDNstatic and pagesView or fragmentrendered HTMLQuery resultsexpensive lookupsDatabasethe sourceCache as far from the database as correctness allows, with explicit invalidation.

Quick answer

  1. Measure first: find the slow or high-volume endpoints and whether the cost is queries, rendering, or external calls.
  2. Fix obvious inefficiencies (N+1 queries, missing indexes) before caching.
  3. Use HTTP caching (with hashed static files and Cache-Control) for anything identical for all users.
  4. Cache whole views only when the response does not vary per user, or vary it explicitly.
  5. Prefer low-level caching of computed data with explicit keys and timeouts for shared expensive results.
  6. Use template fragment caching for expensive, shareable parts of pages that also contain per-user content.
  7. Invalidate deliberately (versioned keys, signals, or short timeouts), protect against stampedes, and monitor hit rate and freshness.

Step-by-step solution

1. Decide what is worth caching

Profile real traffic before adding caches. Good candidates share three properties:

  • expensive: measurable time in queries, rendering, or remote calls
  • requested often: high traffic or many repeated computations
  • tolerant of staleness: a small delay in reflecting changes is acceptable, or changes are rare and invalidation is easy
which cache layer fits?Is the result the same for every visitor?yes, whole pageHTTP / CDN cacheCache-Control headersshared, expensive partLow-level or fragmentcache.get_or_setper userFix the query firstor cache keyed by userThe further out the cache, the more work it savesdjango-deployment.com
Measure first: caching the wrong thing adds invalidation bugs without saving time.

Poor candidates: per-user data requested once, values that must be exact (account balances during checkout), or pages that are already fast.

Useful measurements:

  • p95 latency and request count per URL pattern from access logs or APM
  • query count and time per view (for example with django-debug-toolbar in staging or query logging)
  • database CPU and slow query logs

2. Fix the underlying cost when possible

A page that runs 300 queries because of an N+1 pattern should be fixed with select_related and prefetch_related, not cached. Caching it hides the problem until the first cache miss under load, which is exactly when the cost hurts most.

3. HTTP caching for shared, public responses

Browsers and CDNs can cache responses without Django running at all. Django provides decorators:

from django.views.decorators.cache import cache_control
from django.views.decorators.http import condition


@cache_control(public=True, max_age=300)
def pricing_page(request):
    ...
headers that let a CDN cache safelyCache-Control:public,max-age=300, stale-while-revalidate=60Vary: Accept-EncodingETag: "a3f1c2"12341Shared caches may store it; never for per-user pages.2Fresh for five minutes.3Adding Cookie here makes most CDNs stop caching.4Lets clients revalidate cheaply with a 304.A Set-Cookie on the response usually makes it uncacheabledjango-deployment.com
Check the real headers with curl -I after deploying; middleware can change them.

For static assets, use hashed filenames and long lifetimes (ManifestStaticFilesStorage or WhiteNoise). For HTML, short max-age values plus a CDN can absorb traffic spikes for public pages.

Rules:

  • never mark responses public if they contain user-specific data, CSRF tokens in forms, or Set-Cookie headers
  • Django adds Vary: Cookie when the session is accessed, which prevents shared caches from mixing users, but many CDNs ignore Vary: Cookie or refuse to cache such responses, so keep personalized pages uncached at the edge

4. Whole-view caching

from django.views.decorators.cache import cache_page


@cache_page(60 * 5, key_prefix="public-blog")
def blog_index(request):
    ...

cache_page stores the full response in Django's cache and also sets HTTP caching headers. It builds keys from the URL (including query string) and headers listed in Vary. That makes it safe for anonymous, public pages and risky for anything that varies by user in ways not reflected in Vary.

The per-site cache (UpdateCacheMiddleware and FetchFromCacheMiddleware) applies the same idea to every GET and HEAD response. It is rarely a good fit for applications with logins, because any mistake leaks personalized pages. Prefer per-view decisions.

5. Low-level caching of computed values

Most production caching in Django apps is this layer: caching the expensive part, not the whole response.

from django.core.cache import cache
from django.db import models

from catalog.models import Product


def get_category_counts():
    key = "catalog:category-counts:v2"
    counts = cache.get(key)
    if counts is None:
        counts = dict(
            Product.objects.filter(active=True)
            .values_list("category__slug")
            .annotate(n=models.Count("id"))
        )
        cache.set(key, counts, timeout=600)
    return counts

Guidelines:

  • Explicit keys with a namespace and version suffix (:v2), so a change in data format can be rolled out by changing the key.
  • Always set a timeout. Even with explicit invalidation, a timeout bounds how long a missed invalidation can serve stale data.
  • Cache small, serializable values. Lists of IDs or dictionaries are safer than model instances, which can carry stale related objects and are larger to pickle.
  • Include every input in the key. If the result depends on language, currency, or permissions, those belong in the key.

cache.get_or_set(key, callable, timeout) is a shorthand, but it does not prevent concurrent recomputation (step 8).

6. Template fragment caching

When a page mixes per-user content (header with the user's name) and expensive shared content (a product grid), cache only the shared fragment:

{% load cache %}
<header>Hello, {{ request.user.first_name }}</header>

{% cache 600 product_grid category.pk LANGUAGE_CODE %}
  {% for product in products %}
    {% include "catalog/_product_card.html" %}
  {% endfor %}
{% endcache %}

The extra arguments (category.pk, LANGUAGE_CODE) become part of the key. Note that the view still runs its queries unless the queryset is lazy and only evaluated inside the cached block; querysets passed to the template are evaluated when iterated, so a cache hit skips the database work.

7. Invalidation strategies

Pick one per cached item and document it:

StrategyHowGood for
Time-basedshort timeouts onlydata where brief staleness is fine
Event-baseddelete or update keys in post_save/post_delete signals or service functionsdata edited through your app
Versioned keysinclude a version number stored separately, increment on changegroups of related keys
Deploy-basedchange KEY_PREFIX or VERSION per releasecached data whose format changes with code
three ways to invalidateHowTrade-offShort TTLlet entries expire!stale for up to the TTLDelete on savepost_save signal deletes key!misses bulk updatesVersioned keysbump a version in the key✓old keys linger until evictedMost sites combine a TTL with one of the other twodjango-deployment.com
queryset.update() and bulk_create() do not send post_save.

Event-based invalidation example:

from django.core.cache import cache
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver


@receiver([post_save, post_delete], sender=Product)
def invalidate_category_counts(sender, **kwargs):
    transaction.on_commit(lambda: cache.delete("catalog:category-counts:v2"))

Deleting after commit prevents another request from re-caching the old value between the delete and the commit. Signals do not fire for QuerySet.update() or bulk operations, so pair them with timeouts.

8. Protect against cache stampedes

When a popular key expires, many concurrent requests miss at once and all recompute the value, which can overload the database. Mitigations:

Lock during recomputation:

import time

from django.core.cache import cache


def get_or_compute(key, compute, timeout=600, lock_timeout=30):
    value = cache.get(key)
    if value is not None:
        return value
    lock_key = f"{key}:lock"
    if cache.add(lock_key, "1", lock_timeout):
        try:
            value = compute()
            cache.set(key, value, timeout)
            return value
        finally:
            cache.delete(lock_key)
    for _ in range(20):
        time.sleep(0.1)
        value = cache.get(key)
        if value is not None:
            return value
    return compute()
one expiry, a hundred recomputesHot key expiresat 12:00:00100 requests misssame secondAll recompute100 heavy queriesDatabase saturatestimeoutsWith a lockone recomputes, others waitLocks, jittered TTLs, or early refresh all prevent itdjango-deployment.com
Adding random jitter to TTLs stops many keys expiring at the same moment.

Only one process recomputes; others wait briefly and read the fresh value, falling back to computing after a short wait.

Jittered timeouts: add randomness to timeouts (timeout=600 + random.randint(0, 120)) so related keys do not expire simultaneously.

Background refresh: for critical values, refresh the cache from a scheduled task before expiry, so user requests rarely compute.

9. Choose the backend

  • Redis: the usual choice; shared across processes and servers, supports eviction policies.
  • Memcached: simple and fast for pure caching; no persistence or data structures.
  • Database cache: no extra service; slower, suitable for low volume.
  • Local memory: per process; fine for tests or single-process development, misleading in production with multiple workers.

10. Measure the effect

Track at least:

  • hit ratio per cache (Redis keyspace_hits versus keyspace_misses, or instrument your helpers)
  • latency and query counts for cached endpoints before and after
  • memory usage and evictions

A cache with a low hit ratio adds network round trips and complexity without benefit; remove it or change the key design.

Verification check:

  • cached endpoints show lower p95 latency and fewer queries
  • no personalized content appears for other users (test with two accounts)
  • edits through the admin or app appear within the intended freshness window
  • cache failures degrade performance but do not break pages

Explanation

Caching trades freshness and complexity for speed. The layers closest to the user (browser, CDN) save the most work but can only hold content that is identical for everyone. Layers inside Django can cache smaller, shared pieces of personalized pages. A sound strategy starts from measurements, caches the most expensive shared computations with explicit keys and timeouts, and treats invalidation and stampede behavior as part of the design rather than an afterthought.

Edge cases / notes

Caching and authentication

cache_page on views that check permissions can serve a response rendered for an authorized user to an unauthorized one if the URL is the same. Keep permission-dependent views uncached, or include the relevant permissions in fragment keys.

Querysets in the cache

Caching an unevaluated queryset stores the query, not the results. Evaluate to a list or store IDs.

Cache warming after deploy

If deploys change key versions, the first requests after release miss everything. Warm critical keys in a post-deploy task.

Common mistakes

  • Caching slow pages instead of fixing N+1 queries.
  • Using LocMemCache in production and wondering why invalidation works only sometimes.
  • Caching per-user responses with cache_page.
  • Setting timeouts to None and relying entirely on signals for invalidation.

Next steps

FAQ

What should I cache first in a Django application?

Start with expensive, frequently requested computations that many users share, such as navigation data, counts, or rendered product lists, after fixing obvious query inefficiencies.

Is cache_page safe for logged-in users?

Only if the response does not vary by user or the variation is reflected in the cache key. For personalized pages, cache shared fragments or computed data instead.

How do I invalidate Django cache entries when data changes?

Delete or update keys when the data changes, for example in a service function or signal handler run after commit, and keep timeouts as a safety net for missed invalidations.

What is a cache stampede and how do I prevent it?

Many requests missing the same expired key and recomputing it at once. Prevent it with a recomputation lock, jittered timeouts, or background refresh of hot keys.

Learning path · step 6 of 12

Making Django fast

Keep going