skip to content

A Django product-detail view is expensive to render; how would you use the condition() decorator so unchanged pages return 304 without running the view, and what pitfalls apply?

level: seniorimportance: should knowfreq 42%

answer

  1. cheap callbacks, expensive view
  2. callbacks get the view's arguments
  3. the view body is skipped entirely
  4. one condition(), not two decorators
  5. Vary and Cache-Control go above

basics

~20 s

Decorate the view with condition(etag_func=..., last_modified_func=...) using cheap callbacks that take the view's arguments; on a matching conditional GET Django returns 304 without calling the view. Use one condition() rather than stacked etag() and last_modified(), and keep vary or cache_control decorators above it.

solid answer

~40 s

`condition(etag_func=None, last_modified_func=None)` from `django.views.decorators.http` calls your callbacks with the same `request, *args, **kwargs` as the view, before the view runs. If the request's `If-None-Match` or `If-Modified-Since` matches, it returns a 304 immediately and the expensive view never executes; otherwise it runs the view and adds `ETag` and `Last-Modified` to `GET`/`HEAD` responses. The callbacks must be cheap, for example `Product.objects.filter(slug=slug).values_list("updated_at", flat=True).first()`. Pitfalls: stacking `@etag` and `@last_modified` gives wrong answers, so pass both to one `condition()`; decorators below `condition()` are skipped on a 304, so `vary_on_cookie` or `cache_control` must sit above it; an unquoted ETag becomes a strong one; and on `PUT`/`POST` a failed `If-Match` yields 412. `ConditionalGetMiddleware` differs: it only compares after the full response was built.

code

python · 24 lines
python
from django.shortcuts import get_object_or_404, render
from django.views.decorators.cache import cache_control
from django.views.decorators.http import condition, require_safe
from django.views.decorators.vary import vary_on_cookie

from .models import Product


def product_version(request, slug):
    v = Product.objects.filter(slug=slug).values_list("version", flat=True).first()
    return None if v is None else f'"p-{slug}-{v}"'


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


@require_safe
@cache_control(private=True, max_age=0)
@vary_on_cookie  # above condition(): 304s must carry Vary too
@condition(etag_func=product_version, last_modified_func=product_changed)
def product_detail(request, slug):
    product = get_object_or_404(Product.objects.select_related("brand"), slug=slug)
    return render(request, "catalogue/product_detail.html", {"product": product})

go deeper

for a junior

Recall that condition(), etag() and last_modified() live in django.views.decorators.http and can answer 304 for unchanged pages.

for a middle

Explain the flow: callbacks with the view's arguments, get_conditional_response, 304 or 412 without calling the view, headers added on GET and HEAD.

for a senior

Choose validators that cover everything the page shows, keep callbacks cheap, order vary and cache_control above condition(), and reuse them for 412 on writes.

for a principal

Decide where revalidation belongs, per view with condition(), globally with middleware or in a shared cache, weighing validator upkeep against saved rendering.

## The problem the decorator solves A product-detail page may join prices, stock levels, reviews and recommendations before rendering a large template. Browsers and caches that already hold the page revalidate it with `If-None-Match` or `If-Modified-Since`. If Django has to build the whole page just to discover that nothing changed, revalidation saves bandwidth but not server work. `condition()` moves the comparison **in front of** the view. ## How condition() works The signature is `condition(etag_func=None, last_modified_func=None)`. For each request the wrapper: 1. calls `last_modified_func(request, *args, **kwargs)` if given; the result must be a `datetime` or `None` (a naive value is treated as UTC); 2. calls `etag_func(request, *args, **kwargs)` if given; the result is a string or `None`, and an unquoted string is wrapped in quotes with `quote_etag()`, which makes it a **strong** ETag; 3. passes both values to Django's `get_conditional_response()`, which evaluates the request's precondition headers; 4. if that returns a response (304 or 412), returns it **without calling the view**; 5. otherwise calls the view and, for `GET` and `HEAD` only, adds `ETag` (with `setdefault`) and `Last-Modified` (if absent) to the response. The callbacks receive the same arguments as the view, so a URL like `path("products/<slug:slug>/", product_detail)` gives the callback the `slug` too. ## Making the callbacks cheap The decorator only pays off if the callbacks are much cheaper than the view: - read one indexed column: `Product.objects.filter(slug=slug).values_list("updated_at", flat=True).first()`; - or keep a `version` integer bumped on every save and use it as the ETag; - remember that everything the page shows must feed the validator. If the page also displays live stock, a product `updated_at` alone will return 304 while the stock number changed. Returning `None` from both callbacks means "no validator", and the view simply runs. ## Pitfalls interviewers look for - **Do not stack `@etag` and `@last_modified`.** `etag()` and `last_modified()` are shortcuts for `condition()` with one argument. Stacked, the outer decorator does not know about the inner one and may answer 304 even though the other validator says the page changed. Pass both callbacks to a single `condition()`. - **Decorator order.** When `condition()` returns a 304, any decorator *below* it never runs. The documentation therefore says `vary_on_cookie`, `vary_on_headers` and `cache_control` belong *above* `condition()`, because RFC 9110 requires their headers on 304 responses too. - **Unsafe methods.** On `PUT`, `POST` or `DELETE`, a failed `If-Match` or `If-Unmodified-Since` returns **412 Precondition Failed**. Use the *same* callbacks for reads and writes so the values agree. Validator headers are only added automatically on `GET`/`HEAD`. - **Async views.** The callbacks are called synchronously even for an `async def` view, so they must not use the synchronous ORM there. ## Testing the conditional path Two assertions prove that the decorator saves work and not just bytes: 1. Fetch the page once and read `response["ETag"]`. 2. Fetch it again with the test client's `headers={"if-none-match": etag}` and assert `status_code == 304`. 3. Wrap the second request in `assertNumQueries(2)`: only the two callbacks' single-column queries should run (one each in the example view), proving the view body with its joins was skipped. 4. Change the product (bump `version` or `updated_at`) and assert the next conditional request returns 200 with a new ETag. For writes, send `PUT` with an outdated `if-match` value and assert a 412 and an unchanged row. ## condition() versus ConditionalGetMiddleware | | `condition()` | `ConditionalGetMiddleware` | |---|---|---| | Scope | one view | every view | | When it compares | before the view runs | after the full response exists | | Saves server work | yes, on a match | no, only bandwidth | | Methods | `GET`/`HEAD` 304s, 412s on writes | `GET` only | | ETag source | your callback | an MD5 of the body if none is set | The documentation's rule of thumb: if views are already fast, the middleware is enough; if you can compute validators quickly and a view is slow, use `condition()`.

  • Why is stacking @etag(f) on top of @last_modified(g) wrong?
    Each is a separate `condition()` with one validator. The outer one evaluates only its own value and can return 304 when the ETag matches, even if the inner `last_modified` value would have said the page changed, or vice versa. One `condition(etag_func=f, last_modified_func=g)` evaluates both together, as the documentation requires.
  • The 304 responses from your product page are missing the Vary header. What is wrong?
    `vary_on_cookie` (or `cache_control`) sits below `condition()`. On a match `condition()` returns the 304 without calling what it wraps, so those decorators never run. Move them above `condition()` so they patch both the 200 and the 304.
  • What does condition() return for a PUT whose If-Match no longer matches the product's ETag?
    A 412 Precondition Failed, produced before the view runs, so the update is not applied. That is optimistic concurrency: the client refetches the product and retries. The same `etag_func` must serve both `GET` and `PUT` so the values match.

condition() is a receptionist who checks the visitor's printed version number against the one on file and waves them off if it matches, before anyone walks down to the archive to fetch the full document.

saying these in an interview costs you the question

  • condition() still runs the view and only compares afterwards
  • Stacking @etag and @last_modified is equivalent to condition() with both
  • Decorator order around condition() does not matter
  • ConditionalGetMiddleware avoids rendering unchanged pages
  • The etag callback receives only the request, not the URL arguments
  • An updated_at column alone is always a safe validator for a page with live stock