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.
- Add
django.middleware.http.ConditionalGetMiddlewareto get ETags computed from response bodies and automatic 304s. - For expensive views, use the
@condition,@etag, or@last_modifieddecorators with cheap functions so Django can return 304 without running the view. - Base ETags on a version number or
updated_attimestamp, not on rendering the response. - Send
Cache-Control: private, no-cache(ormax-age=0, must-revalidate) so clients revalidate instead of using stale copies blindly. - Make sure your proxy or CDN passes
If-None-Matchthrough 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.
3. Choose good validators
- Version counters: increment a
versionfield on save; the ETag is the version. Exact and cheap. updated_attimestamps: good forLast-Modified, but HTTP dates have one-second precision.- Collections: for a list endpoint, combine the newest
updated_atand 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.
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
conditiondecorator also handlesIf-Matchfor 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
conditionor use a package such as drf-extensions.
Next steps
- Set the right headers with Django Cache-Control Headers for CDNs.
- Speed up APIs further with Optimize Django REST Framework Performance.
- Plan caching layers with Django Caching Strategy for Production.
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.