Use ETags and Conditional Requests in Django

Cut bandwidth and server work with HTTP conditional requests in Django: ConditionalGetMiddleware, the condition, etag, and last_modified decorators, cheap ETag functions, 304 Not Modified responses for APIs, and how ETags interact with CDNs and compression.

Problem statement

Clients poll your Django API every few seconds for data that rarely changes, and browsers re-download large pages that are identical to what they already have. Each request runs queries, renders templates or serializers, and sends the full body. HTTP conditional requests let the client say "I have version X" (If-None-Match) or "I have it from time T" (If-Modified-Since); if nothing changed, Django returns 304 Not Modified with no body. Done well, Django can also skip the expensive work entirely.

Conditional GET in DjangoFirst responseETag headerClient repeatsIf-None-MatchDjango comparescheap check304 Not Modifiedno bodyA matching ETag turns a full response into an empty 304.
  1. Add django.middleware.http.ConditionalGetMiddleware to get ETags computed from response bodies and automatic 304s.
  2. For expensive views, use the @condition, @etag, or @last_modified decorators with cheap functions so Django can return 304 without running the view.
  3. Base ETags on a version number or updated_at timestamp, not on rendering the response.
  4. Send Cache-Control: private, no-cache (or max-age=0, must-revalidate) so clients revalidate instead of using stale copies blindly.
  5. Make sure your proxy or CDN passes If-None-Match through and does not strip ETags when compressing.

Step-by-step solution

1. Enable ConditionalGetMiddleware

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.http.ConditionalGetMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

For responses without an ETag, the middleware computes one from the body (an MD5 of the content) and returns 304 when it matches the request's If-None-Match. This saves bandwidth, but the view still runs and renders, so server work is unchanged.

2. Skip work with the condition decorator

from django.views.decorators.http import condition

def article_etag(request, slug):
    return Article.objects.filter(slug=slug).values_list("version", flat=True).first()

def article_last_modified(request, slug):
    return Article.objects.filter(slug=slug).values_list("updated_at", flat=True).first()

@condition(etag_func=article_etag, last_modified_func=article_last_modified)
def article_detail(request, slug):
    article = get_object_or_404(Article.objects.select_related("author"), slug=slug)
    return render(request, "articles/detail.html", {"article": article})

Django calls the cheap functions first; if the client's validators match, it returns 304 without running the view at all. The ETag function should be a single indexed query or a cache lookup.

approach and savingsSaves bandwidthSaves server workConditionalGetMiddlewareyes!no, view still runs@condition / @etagyesyes, if func is cheap@last_modifiedyesyes, second precisionDRF custom ETagyesyes, if computed firstDecorators with cheap validators save both bandwidth and CPUdjango-deployment.com
An ETag computed from the rendered body cannot skip the rendering.

3. Choose good validators

  • Version counters: increment a version field on save; the ETag is the version. Exact and cheap.
  • updated_at timestamps: good for Last-Modified, but HTTP dates have one-second precision.
  • Collections: for a list endpoint, combine the newest updated_at and the row count: f'"{max_updated.timestamp()}-{count}"', from one aggregate query.

ETags must change whenever the representation changes, including when it depends on the user, language, or query parameters. Include those in the ETag or vary the cache accordingly.

4. Tell clients to revalidate

from django.views.decorators.cache import cache_control

@cache_control(private=True, no_cache=True)
@condition(etag_func=article_etag)
def article_detail(request, slug):
    ...

no-cache means "store it, but check with the server before using it", which is exactly when conditional requests happen. For public content behind a CDN, use public, max-age=60 plus ETags so the CDN can revalidate with the origin cheaply.

5. Watch proxies and compression

Some servers modify or drop strong ETags when compressing responses (ETag: "abc" may become W/"abc"). Django's GZipMiddleware weakens ETags appropriately. If Nginx compresses, enable etag on; (the default) and test that If-None-Match from the browser still produces 304. CDNs must forward If-None-Match to the origin for revalidation.

request flow with @condition1Client sends If-None-Matchstored ETag2etag_func runsone indexed query3Values matchnothing changed4Django returns 304view never runs5Client reuses its copyno body sentA cheap check replaces rendering, serialization, and transferdjango-deployment.com
Polling clients benefit most: most polls become cheap 304s.

Explanation

Conditional requests are part of HTTP caching. A response carries validators (ETag, Last-Modified); a later request carries them back in If-None-Match or If-Modified-Since. If the server determines the representation is unchanged, it replies 304 with headers only. Django's middleware implements the comparison generically, and the decorators let you compute validators before doing the expensive part of the view, which is where real server savings come from.

Edge cases / notes

  • Unsafe methods. The condition decorator also handles If-Match for PUT and PATCH, enabling optimistic concurrency (412 Precondition Failed on conflicts).
  • CSRF tokens in pages. Pages with per-user tokens produce different bodies per user; use private caching and user-aware ETags.
  • DRF. Django REST Framework does not add ETags by default; wrap views with condition or use a package such as drf-extensions.

Next steps

FAQ

Does ConditionalGetMiddleware make views faster?

It saves bandwidth, but the view still runs. Use the condition decorator to skip the view.

What should an ETag be based on?

A version number or update timestamp that changes whenever the response would change.

Do ETags work with gzip?

Yes, but compression may turn strong ETags into weak ones. Test that 304s still happen through your proxy.

Can ETags prevent lost updates?

Yes. Clients send If-Match on updates, and Django returns 412 if the resource changed since they read it.

Keep going