Function-Based Views & Shortcuts
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15+,
checked on 2026-10-03; where Django 5.2 LTS behaves differently the text says so. The listings were run in a scratch project on
Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, thirteen tests (manage.py test) and a curl session against
runserver passed, and every status code and header shown below is real output. Nothing on this page needs PostgreSQL or Redis;
the select_for_update() call in the reservation example is a no-op on SQLite and takes a row lock on PostgreSQL.
A view is the piece of your code that Django calls once it has found the URL: it receives an HttpRequest and returns an
HttpResponse (or raises an exception that Django turns into one). The simplest view is a plain Python function, and that is
what this page covers: how to write one, which helpers in django.shortcuts remove the boilerplate, which decorators add HTTP
behaviour (allowed methods, caching, compression, CSRF, login), how to answer 304 Not Modified and 412 Precondition Failed
without building the page, and the built-in views Django ships for serving files in development and rendering error pages.
Where a view sits between URL resolution and the response is described on
Request/Response Cycle; mapping URLs to views is on
URLs & Routing; the class-based alternative is on
Class-Based Views and Generic Views; building the
objects views fetch is on Making Queries; and the richer response types (FileResponse, streaming, CSV,
PDF) are on Responses, Streaming, CSV & PDF.
The Bookshelf pieces used on this page
Every example uses the catalog app of the Bookshelf scenario. This page needs only two models; Book.updated is an
auto_now timestamp that the conditional-processing section uses as a cheap "when did this change" value:
from django.db import models
from django.urls import reverse
class Author(models.Model):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("catalog:author-detail", args=[self.pk])
class Book(models.Model):
title = models.CharField(max_length=200)
isbn = models.CharField(max_length=13, unique=True)
author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
price = models.DecimalField(max_digits=6, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
reserved = models.PositiveIntegerField(default=0)
is_published = models.BooleanField(default=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", args=[self.pk])
Source: Django: Models
The model definitions themselves are explained on Models & Fields. The URLconf is a plain list
of path() entries; the names matter because the shortcuts and get_absolute_url() reverse them:
from django.urls import path
from . import views
app_name = "catalog"
urlpatterns = [
path("", views.book_list, name="book-list"),
path("health/", views.health, name="health"),
path("books/<int:pk>/", views.book_detail, name="book-detail"),
path("books/<int:pk>/reserve/", views.book_reserve, name="book-reserve"),
path("books/<int:pk>/etag/", views.book_conditional, name="book-conditional"),
path("authors/<int:pk>/", views.author_detail, name="author-detail"),
path("authors/<int:pk>/books/", views.author_books, name="author-books"),
path("search/", views.book_search, name="book-search"),
# ... the remaining routes appear next to the views that use them
]
Source: Django: URL dispatcher
The project URLconf mounts it with path("catalog/", include("catalog.urls")).
Writing views
A view is any callable that takes an HttpRequest as its first argument, receives the values captured by the URL pattern as
further arguments, and returns an HttpResponse. There is no base class to inherit and no registration step; the name of the
function does not matter, and by convention views live in the app’s views.py.
from django.http import Http404, HttpResponse, HttpResponseNotFound
def health(request):
return HttpResponse("ok", content_type="text/plain")
def created(request):
return HttpResponse(status=201) # any status code through the constructor
def manual_404(request):
return HttpResponseNotFound("<h1>Page not found</h1>") # a subclass for common codes
Source: Django: Writing views
The checks pass on the first three routes:
def test_health_and_201(self):
r = self.client.get("/catalog/health/")
self.assertEqual((r.status_code, r["Content-Type"], r.content), (200, "text/plain", b"ok"))
self.assertEqual(self.client.get("/catalog/errors/created/").status_code, 201)
self.assertEqual(self.client.get("/catalog/errors/manual404/").status_code, 404)
Source: Django: The test client
A few rules follow from that contract:
-
The first parameter is the request. It is conventionally called
request; it carriesmethod,GET,POST,body,headers,user,sessionand the rest (see Request/Response Cycle). -
URL captures arrive as arguments.
path("books/<int:pk>/", …)callsbook_detail(request, pk=3); the converter has already turned the text into anint, so no manual parsing is needed. -
A view must return a response. A function that falls off the end returns
None; Django raisesValueError("The view … didn’t return an HttpResponse object. It returned None instead"). -
Status helpers exist for the common codes.
HttpResponseNotFound,HttpResponseForbidden,HttpResponseBadRequest,HttpResponseNotAllowed,HttpResponseGone,HttpResponseServerErrorand the redirect classes are all indjango.http; for any other code passstatus=toHttpResponse. -
Views are stateless. Module-level mutable state is shared across every request and thread; keep per-request data in local variables, the session or the database.
Exceptions that become responses
Returning HttpResponseNotFound(…) makes you responsible for the page’s HTML. Raising an exception instead lets Django pick the
standard error view, so every error page on the site looks the same and can be customised in one place. The exceptions views
usually raise are:
| Raise | Status | Handled by |
|---|---|---|
|
404 |
|
|
403 |
|
|
400 |
|
|
400 |
Same as |
Any other uncaught exception |
500 |
|
The first two lines of the table are what the shortcuts below rely on: get_object_or_404() is nothing more than a lookup that
raises Http404. The error views and how to customise them are covered in Built-in views.
Sync, async and where a view runs
A function declared with def is a synchronous view; one declared with async def is an asynchronous view. Django detects which
it is and adapts: under a WSGI server a sync view runs in the worker thread and an async view gets a one-off event loop per
request (it works, but there is no concurrency benefit); under an ASGI server an async view runs on the event loop, while a
sync view is run in a thread pool through sync_to_async() so it cannot block the loop. Mixing both styles in one project is
supported; the cost is a thread hop for each sync view or sync middleware on an ASGI stack.
from django.http import JsonResponse
from django.shortcuts import aget_list_or_404, aget_object_or_404
from django.views.decorators.http import require_GET
@require_GET
async def async_book(request, pk):
book = await aget_object_or_404(Book, pk=pk)
return JsonResponse({"title": book.title})
async def async_author_books(request, pk):
books = await aget_list_or_404(Book, author_id=pk)
return JsonResponse({"titles": [b.title for b in books]})
def test_async(self):
r = self.client.get(f"/catalog/async/books/{self.book.pk}/")
self.assertEqual(r.json(), {"title": "The Dispossessed"})
self.assertEqual(self.client.post(f"/catalog/async/books/{self.book.pk}/").status_code, 405)
self.assertEqual(self.client.get("/catalog/async/books/9999/").status_code, 404)
Inside an async view, synchronous ORM calls (Book.objects.get(), iterating a QuerySet) raise SynchronousOnlyOperation; use
the async API (aget(), async for, acount()) as described on Making Queries, the
a-prefixed shortcuts below, or sync_to_async(). The decorators on this page work on both kinds of view
(Decorators and async views). The full async model, including ASGI deployment, is the subject of
Async Support.
Shortcuts: django.shortcuts
django.shortcuts collects helpers that span the layers of MTV: they combine a template loader with a response, or the ORM
with Http404, so that a typical view is three lines long. Each one is a convenience over lower-level APIs, and the docs show
the equivalent longhand for each.
render()
render(request, template_name, context=None, content_type=None, status=None, using=None) loads the template, renders it with
the context and the request (so context processors such as request, user and messages run) and returns an HttpResponse.
template_name may be a sequence, in which case the first template that exists is used. status sets the status code and
using names the template engine. Callable values in context are called just before rendering. It always returns a normal
HttpResponse; if you want a lazily rendered response that middleware or decorators can still modify, build a TemplateResponse
yourself (the docs note that TemplateResponse’s constructor is as convenient as `render(), so there is no shortcut for it).
from django.shortcuts import get_object_or_404, render
from .models import Book
def book_list(request):
books = Book.objects.filter(is_published=True).select_related("author")
return render(request, "catalog/book_list.html", {"books": books})
def book_detail(request, pk):
book = get_object_or_404(Book.objects.select_related("author"), pk=pk, is_published=True)
return render(request, "catalog/book_detail.html", {"book": book})
Source: Django: Shortcuts - render
The detail template contains a small form; the {% csrf_token %} tag is what lets the POST in redirect() and resolve_url() pass CSRF
validation:
<h1>{{ book.title }}</h1>
<p>{{ book.author }} - {{ book.price }}</p>
<form method="post" action="{% url 'catalog:book-reserve' book.pk %}">
{% csrf_token %}
<button type="submit">Reserve</button>
</form>
The template language itself is covered on Templates. Note that render() replaced the
Django 1.x idiom render_to_response("page.html", ctx, context_instance=RequestContext(request)); render_to_response() was removed
in Django 3.0 (see Where the books are outdated).
get_object_or_404() and get_list_or_404()
get_object_or_404(klass, *args, **kwargs) calls get() and raises Http404 instead of DoesNotExist. klass may be a model
class, a Manager or a QuerySet; positional arguments are Q objects and keyword arguments are ordinary lookups. Because a
QuerySet is accepted, the lookup can be restricted to rows the caller may see, which is how the detail view above hides
unpublished books. MultipleObjectsReturned is not translated: a lookup that matches several rows is a bug and surfaces as a 500.
get_list_or_404(klass, *args, **kwargs) returns list(filter(…)) and raises Http404 when that list is empty. Use it only when
"empty" really means "no such page" (a category with no items); for a search that may legitimately return nothing, render an empty
result instead. Both have async twins, aget_object_or_404() and aget_list_or_404(), added in Django 5.0.
from django.http import JsonResponse
from django.shortcuts import get_list_or_404, get_object_or_404
from .models import Author, Book
def author_books(request, pk):
author = get_object_or_404(Author, pk=pk)
books = get_list_or_404(Book, author=author, is_published=True)
return JsonResponse({"author": author.name, "titles": [b.title for b in books]})
Source: Django: Shortcuts - get_object_or_404 and get_list_or_404
def test_list_detail_shortcuts(self):
self.assertContains(self.client.get("/catalog/"), "The Dispossessed")
self.assertNotContains(self.client.get("/catalog/"), "Draft") # unpublished
self.assertEqual(self.client.get(f"/catalog/books/{self.draft.pk}/").status_code, 404)
self.assertEqual(self.client.get("/catalog/books/9999/").status_code, 404)
r = self.client.get(f"/catalog/authors/{self.author.pk}/books/")
self.assertEqual(r.json(), {"author": "Ursula Le Guin", "titles": ["The Dispossessed"]})
empty = Author.objects.create(name="Nobody")
self.assertEqual(self.client.get(f"/catalog/authors/{empty.pk}/books/").status_code, 404)
redirect() and resolve_url()
redirect(to, *args, permanent=False, preserve_request=False, max_length=MAX_URL_REDIRECT_LENGTH, **kwargs) returns an
HttpResponseRedirect (or its permanent twin). The to argument is resolved in this order:
-
a model instance: its
get_absolute_url()is called; -
a view name (or a view callable), with optional positional or keyword arguments, resolved with
reverse(); -
otherwise a URL string used as is, absolute (
"https://example.com/“) or relative (”/catalog/").
By default the redirect is temporary (302); permanent=True makes it 301. preserve_request=True (Django 5.2) asks the
browser to repeat the method and body at the new URL, which uses the 307 and 308 codes:
permanent |
preserve_request |
Status |
|---|---|---|
|
|
302 |
|
|
301 |
|
|
307 |
|
|
308 |
Source: Django: Shortcuts - redirect
max_length (Django 6.1) overrides the maximum URL length the redirect classes accept; pass None to disable the check. Leave it
alone unless you really redirect to very long URLs.
resolve_url(to, *args, **kwargs) is the resolution step of redirect() on its own: it returns the URL string for a model, a
view name or callable, or a literal string, which is useful when the URL goes somewhere other than a redirect, such as a response
header or a JSON payload.
The classic use of redirect() is Post/Redirect/Get: after a successful POST, answer with a redirect to a GET page so a browser
refresh does not resubmit the form. The reservation view below also shows the two other things a state-changing view needs:
@require_POST (a link or a crawler can never trigger it with GET) and a transaction around a read-modify-write:
from django.db import transaction
from django.db.models import F
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.decorators.http import require_POST
@require_POST
def book_reserve(request, pk):
with transaction.atomic():
book = get_object_or_404(Book.objects.select_for_update(), pk=pk)
if book.stock - book.reserved < 1:
raise Http404("Sold out")
Book.objects.filter(pk=book.pk).update(reserved=F("reserved") + 1)
return redirect(book) # Post/Redirect/Get: Book.get_absolute_url() is called
def legacy_book(request, pk):
return redirect("catalog:book-detail", pk=pk, permanent=True) # 301 for an old URL
def v1_book(request, pk):
return redirect("catalog:v2-book", pk=pk, preserve_request=True) # 307: keep method + body
def book_edit(request, pk):
response = HttpResponse("edit")
response["X-Detail-URL"] = resolve_url("catalog:book-detail", pk) # a URL that is not a redirect
return response
Source: Django: Shortcuts - resolve_url
Running the redirects:
def test_redirects(self):
r = self.client.get(f"/catalog/books/{self.book.pk}/old/")
print("legacy:", r.status_code, r["Location"])
r = self.client.post(f"/catalog/books/{self.book.pk}/reserve/")
print("reserve:", r.status_code, r["Location"])
self.assertRedirects(r, self.book.get_absolute_url())
for method in ("get", "post", "put"):
r = getattr(self.client, method)(f"/catalog/v1/books/{self.book.pk}/")
print("v1", method, r.status_code)
legacy: 301 /catalog/books/1/
reserve: 302 /catalog/books/1/
v1 get 307
v1 post 307
v1 put 307
Two details of the reservation view are deliberate. F("reserved") + 1 makes the database do the increment, so two
simultaneous requests cannot overwrite each other’s count; and raise Http404("Sold out") inside atomic() rolls the block
back before the error page is produced. Transactions, select_for_update() and F() are explained on
Transactions, Raw SQL & Multiple Databases and
Aggregation, Expressions & Functions. Never redirect to a URL taken from
the request (?next=…) without checking it with url_has_allowed_host_and_scheme(); see
Security.
View decorators
A decorator wraps a view function and adds behaviour before or after it. Django ships a family of them in
django.views.decorators plus the authentication ones in django.contrib.auth.decorators. The mechanics of decorators in Python
(closures, functools.wraps, decorators with arguments) are explained on
Decorators & Metaclasses; this section covers what Django adds.
| Module | Decorators | What they do |
|---|---|---|
|
|
Answer |
|
|
Conditional GET and conditional update with |
|
|
Patch |
|
|
Set the |
|
|
Compress the body when the client sends |
|
|
Exclude a view from |
|
|
Per-view control over |
|
|
Per-view |
|
|
Per-view Content-Security-Policy (6.0) |
|
|
Hide values in error reports |
|
|
Gate a view on the user |
Source: Django: View decorators
Restricting HTTP methods
require_http_methods(["GET", "POST"]) lets only the listed methods through (names must be uppercase); require_GET,
require_POST and require_safe are shortcuts. require_safe allows GET and HEAD, which the docs prefer over require_GET because
link checkers and caches send HEAD. The rejected request gets 405 and an Allow header listing what is accepted:
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["GET", "HEAD", "OPTIONS"])
def book_search(request):
q = request.GET.get("q", "")
titles = list(Book.objects.filter(title__icontains=q).values_list("title", flat=True))
return JsonResponse({"q": q, "titles": titles})
POST /catalog/search/ -> 405, Allow: GET, HEAD, OPTIONS
GET /catalog/books/1/reserve/ -> 405, Allow: POST
The decorator does not make state changes safe by itself; it only enforces the verb. The state-changing view still needs CSRF protection (the next section) and authentication.
Caching headers: cache_control, never_cache, vary_on_headers
These decorators describe how clients and shared caches may reuse the response; they are separate from the server-side cache framework, which is covered on Caching.
-
cache_control(**kwargs)adds the keyword arguments toCache-Control(max_age=60,public=True,private=True,no_cache=True,s_maxage=…,must_revalidate=True, and so on; underscores become hyphens). -
never_cacheaddsCache-Control: max-age=0, no-cache, no-store, must-revalidate, privateand anExpiresheader; each header is only added if the view has not already set it. Use it for pages that show per-user or just-changed data. -
vary_on_headers("Accept-Language", …)adds names toVary, telling caches to key on those request headers;vary_on_cookieis the shortcut forCookie. Without it a shared cache could serve one user’s French page to another user.
from django.http import HttpResponse, JsonResponse
from django.views.decorators.cache import cache_control, never_cache
from django.views.decorators.http import require_safe
from django.views.decorators.vary import vary_on_headers
@require_safe
@vary_on_headers("Accept-Language")
@cache_control(max_age=60, public=True)
def book_report(request):
return HttpResponse("report", content_type="text/plain")
@never_cache
def book_export(request):
return JsonResponse({"count": Book.objects.count()})
Response headers seen through curl against runserver, and the test-client output for book_export:
$ curl -sI http://127.0.0.1:8922/catalog/report/
HTTP/1.1 200 OK
Cache-Control: max-age=60, public
Vary: Accept-Language
test client, GET /catalog/export/:
Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private
Expires: (a date in the past)
A POST to /catalog/report/ is answered 405 with Allow: GET, HEAD by the test client, but 403 over HTTP with curl: the CSRF
middleware rejects the token-less POST before the view’s require_safe is reached, and the test client skips CSRF checks unless
created with enforce_csrf_checks=True.
In Django 6.1 the cache keys produced for pages that vary on extra headers changed, so the first request to a previously cached page after upgrading is a miss (release notes, "Miscellaneous");
gzip_page
gzip_page compresses the response body when the browser sends Accept-Encoding: gzip and adds Vary: Accept-Encoding so caches
store both variants. It is useful for a few large text responses; for a whole site, compress in the front-end web server instead, because
compressing a response that contains a secret next to attacker-controlled text can leak the secret (the BREACH attack). The
documentation of GZipMiddleware explains the risk and the mitigation Django applies (a few random bytes appended to each
compressed response); a per-view decorator lets you limit compression to responses that carry no secrets.
from django.http import HttpResponse
from django.views.decorators.gzip import gzip_page
@gzip_page
def book_big(request):
return HttpResponse("x" * 5000)
with Accept-Encoding: gzip -> Content-Encoding: gzip, Vary: Accept-Encoding, body about 70-95 bytes of 5000 (the size varies per request because of random padding)
without the header -> no Content-Encoding, body 5000 bytes
CSRF decorators
CsrfViewMiddleware is normally enabled for the whole project and rejects unsafe-method requests that lack a valid token. The
decorators in django.views.decorators.csrf give per-view control: csrf_protect applies the protection to a view when the middleware is
not installed; requires_csrf_token makes the token available in the template context without enforcing it; ensure_csrf_cookie
forces the CSRF cookie to be sent (needed for a JavaScript client that reads the cookie before its first POST); and csrf_exempt turns
the check off. csrf_exempt is almost never right for a browser-facing view; legitimate uses are webhooks verified by a
signature of their own. The behaviour of the middleware is covered on Security.
def test_csrf_enforced(self):
from django.test import Client
c = Client(enforce_csrf_checks=True)
r = c.post(f"/catalog/books/{self.book.pk}/reserve/")
print("csrf:", r.status_code) # 403: the POST has no CSRF token
Source: Django: CSRF - decorators
csrf: 403
Authentication decorators
login_required(redirect_field_name="next", login_url=None) redirects anonymous users to settings.LOGIN_URL, appending the current
path as ?next=. user_passes_test(test_func, login_url=None, redirect_field_name="next") does the same unless test_func(user) is
true, and permission_required(perm, login_url=None, raise_exception=False) checks a permission (with raise_exception=True it
raises PermissionDenied, so the user sees a 403 instead of the login page). Since Django 5.1 these decorators also wrap async
views, and login_not_required() exempts a view when LoginRequiredMiddleware makes login the default for the whole project.
Authorization design (object-level checks, groups) is on Permissions & Authorization.
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
@login_required
def my_reservations(request):
return HttpResponse(f"hello {request.user}")
anonymous -> 302 /accounts/login/?next=/catalog/mine/
logged in -> 200 "hello ann"
Stacking order
Decorators apply from the bottom up and run from the top down: in book_report above, require_safe receives the request first,
then vary_on_headers, then cache_control, then the view; the response travels back the other way. Two rules come out of this:
-
Put gatekeeping decorators (
require_*,login_required) on the outside so a rejected request does no further work. -
Put header decorators on the outside of
condition(), because a304returned bycondition()skips everything beneath it (The decorator-order trap).
The CSRF middleware runs even before the view and its decorators, so a decorator can never see a request the middleware rejected.
Writing your own decorator
A decorator is a function that takes a view and returns a view. Use functools.wraps so the wrapper keeps the view’s name and
docstring (URL names, the admin docs and error reports use them). To support both kinds of view, return an async def wrapper
for coroutine functions; inspect.iscoroutinefunction() is the test (in Python 3.14 asyncio.iscoroutinefunction() is deprecated):
import functools
import inspect
import time
def timed(view):
"""Add an X-Elapsed-Ms header; works on sync and async views."""
if inspect.iscoroutinefunction(view):
@functools.wraps(view)
async def wrapper(request, *args, **kwargs):
start = time.perf_counter()
response = await view(request, *args, **kwargs)
response["X-Elapsed-Ms"] = f"{(time.perf_counter() - start) * 1000:.1f}"
return response
else:
@functools.wraps(view)
def wrapper(request, *args, **kwargs):
start = time.perf_counter()
response = view(request, *args, **kwargs)
response["X-Elapsed-Ms"] = f"{(time.perf_counter() - start) * 1000:.1f}"
return response
return wrapper
Applied to async_book above (@require_GET outside, @timed inside), the test client receives the extra header:
async: 200 {'title': 'The Dispossessed'} X-Elapsed-Ms present
If the same behaviour must run for every request, write middleware instead (see Middleware).
To apply a function decorator to a method of a class-based view, wrap it with django.utils.decorators.method_decorator
(which supports async methods since Django 5.2); see Class-Based Views.
Decorators and async views
Every decorator in the table above, including the authentication ones, can wrap an async def view. The CSRF, caching,
conditional, gzip, HTTP-method, vary, debug and clickjacking decorators gained this in Django 5.0, the authentication decorators in
Django 5.1, and method_decorator in 5.2; Custom decorators need the explicit branch shown above.
Conditional view processing
HTTP lets a client say "I already have a copy": If-None-Match carries the ETag it last received and If-Modified-Since the
Last-Modified date. If nothing changed, the server can answer 304 Not Modified with no body. The same validators protect updates:
a client sends If-Match with the ETag it edited, and the server answers 412 Precondition Failed when the resource has changed in
the meantime (the "lost update" problem). Django offers two tools, with different costs.
ConditionalGetMiddleware: simple, global, still builds the page
django.middleware.http.ConditionalGetMiddleware adds an ETag (a hash of the body) when the response has none, and replaces the
response with 304 when the request’s If-None-Match or If-Modified-Since matches. It needs no changes to views, but the view
still runs and the page is still built; only the network transfer is saved. It is global and only meaningful for GET. The
conditional_page decorator gives the same behaviour to a single view. The middleware is explained further on
Middleware.
class ConditionalMiddlewareTests(TestCase):
@override_settings(MIDDLEWARE=["django.middleware.http.ConditionalGetMiddleware"])
def test_middleware(self):
r = self.client.get("/catalog/health/")
print("mw:", r.status_code, r["ETag"])
r = self.client.get("/catalog/health/", headers={"If-None-Match": r["ETag"]})
print("mw INM:", r.status_code)
Source: Django: ConditionalGetMiddleware
mw: 200 "444bcb3a3fcf8389296c49467f27e1d6"
mw INM: 304
condition, etag and last_modified: skip the work
When the validator is much cheaper than the page, condition(etag_func=None, last_modified_func=None) bails out before the view
runs. Each function receives the request and the same arguments as the view (so pk is available) and returns:
-
etag_func: a string (Django adds the quotes) orNonewhen the resource does not exist; -
last_modified_func: adatetime(timezone-aware whenUSE_TZ = True) orNone.
The decorator then compares them with If-None-Match, If-Match, If-Modified-Since and If-Unmodified-Since, calls the view
only if a fresh copy is needed, and for safe methods (GET and HEAD) sets ETag and Last-Modified on the response unless the view
already set them. etag and last_modified are the single-validator versions. A function that returns None does not make
the request 404; the view still runs and decides, which is why the view below also uses get_object_or_404().
The same two functions serve a GET (conditional fetch) and a PUT (conditional update), exactly as the docs recommend:
from decimal import Decimal, InvalidOperation
from django.core.exceptions import BadRequest
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import condition, require_http_methods
from django.views.decorators.vary import vary_on_headers
def book_etag(request, pk):
updated = Book.objects.filter(pk=pk).values_list("updated", flat=True).first()
return f"{pk}-{updated.timestamp()}" if updated else None
def book_last_modified(request, pk):
return Book.objects.filter(pk=pk).values_list("updated", flat=True).first()
@vary_on_headers("Accept")
@condition(etag_func=book_etag, last_modified_func=book_last_modified)
@require_http_methods(["GET", "HEAD", "PUT"])
def book_conditional(request, pk):
book = get_object_or_404(Book, pk=pk)
if request.method == "PUT":
try:
book.price = Decimal(request.body.decode().strip())
except InvalidOperation:
raise BadRequest("price must be a number")
book.save(update_fields=["price", "updated"])
response = JsonResponse({"price": str(book.price)})
response["ETag"] = f'"{book_etag(request, pk)}"' # validators on a non-safe method are the view's job
return response
return JsonResponse({"title": book.title, "price": str(book.price)})
Each validator costs one indexed one-column query, which is the point: a 304 takes a single SELECT instead of rendering a
page. Exercising every branch:
def test_conditional(self):
url = f"/catalog/books/{self.book.pk}/etag/"
r = self.client.get(url)
etag, lm = r["ETag"], r["Last-Modified"]
print("first:", r.status_code, etag, "|", lm, "|", r["Vary"])
r = self.client.get(url, headers={"If-None-Match": etag})
print("INM:", r.status_code, len(r.content), r["ETag"], "| Vary:", r["Vary"])
r = self.client.get(url, headers={"If-Modified-Since": lm})
print("IMS:", r.status_code)
r = self.client.get(url, headers={"If-None-Match": '"stale"'})
print("stale INM:", r.status_code)
r = self.client.put(url, "12.50", content_type="text/plain", headers={"If-Match": '"stale"'})
print("PUT stale:", r.status_code)
r = self.client.put(url, "12.50", content_type="text/plain", headers={"If-Match": etag})
print("PUT ok:", r.status_code, r.json(), r["ETag"])
r = self.client.put(url, "13", content_type="text/plain", headers={"If-Match": etag})
print("PUT replay:", r.status_code)
self.assertEqual(self.client.get("/catalog/books/9999/etag/").status_code, 404)
first: 200 "1-1790984140.150587" | Fri, 02 Oct 2026 23:35:40 GMT | Accept
INM: 304 0 "1-1790984140.150587" | Vary: Accept
IMS: 304
stale INM: 200
PUT stale: 412
PUT ok: 200 {'price': '12.50'} "1-1790984140.156485"
PUT replay: 412
The last line is the whole point of If-Match: the second client still holds the old ETag, the first update changed
updated, so the replay is refused instead of silently overwriting the new price. The same exchange over HTTP, from the curl
session against runserver:
$ curl -si http://127.0.0.1:8922/catalog/books/1/etag/
HTTP/1.1 200 OK
Content-Type: application/json
Last-Modified: Fri, 02 Oct 2026 23:35:53 GMT
ETag: "1-1790984153.568721"
Vary: Accept
...
{"title": "The Dispossessed", "price": "9.99"}
$ curl -si -H 'If-None-Match: "1-1790984153.568721"' http://127.0.0.1:8922/catalog/books/1/etag/
HTTP/1.1 304 Not Modified
Last-Modified: Fri, 02 Oct 2026 23:35:53 GMT
ETag: "1-1790984153.568721"
Vary: Accept
Content-Length: 0
(A curl -X PUT to this URL is answered 403 by CSRF protection, because the view is a browser-style Django view; machine
clients that update resources normally use an API layer with token authentication, see
Django REST Framework. The test client skips CSRF by default, which is why the test above can
send the PUT.)
The decorator-order trap
When condition() answers 304 or 412, the decorators below it are skipped. RFC 9110 requires Vary, Cache-Control and the
like to be present on a 304, so decorators that set those headers must sit above condition(). The test below runs the same
view both ways and prints the Vary header of the 304:
@condition(etag_func=etag)
@vary_on_headers("Accept")
def wrong(request):
return JsonResponse({})
@vary_on_headers("Accept")
@condition(etag_func=etag)
def right(request):
return JsonResponse({})
# request each with If-None-Match: "v1" and print status and Vary
wrong 304 None
right 304 Accept
Two further rules from the docs. First, do not chain @etag(…) and @last_modified(…) to test both validators: the first
decorator knows nothing about the second and may answer "not modified" when the second would disagree; use one condition()
call. Second, for non-safe methods condition does not set validators on the response, as the view above does by hand
(RFC 9110 distinguishes the PUT and POST cases).
Choose between the two tools by cost: if pages are cheap to build, ConditionalGetMiddleware saves bandwidth with no code; if a
page is expensive and a validator is cheap (a timestamp column, a version counter), use condition() and avoid the work. The
ETag function should return the same value for the same state every time, so derive it from data that changes with the
content (a version column, updated, a hash), never from id(…) or the current time. Django 3.1 also stopped
ConditionalGetMiddleware from adding an ETag to responses with an empty body.
Built-in views
django.views ships a small set of ready-made views. They are the ones you serve files with in development and the ones Django
calls when a request ends in an error.
Serving files in development: static.serve() and static()
django.views.static.serve(request, path, document_root, show_indexes=False) serves any directory. It exists for development (the
docs state it is not hardened for production, where a real front-end web server or object storage should serve the files); the usual
use is user uploads under MEDIA_ROOT, which django.contrib.staticfiles deliberately does not handle. The helper
django.conf.urls.static.static(prefix, document_root=…) builds the URL pattern for you and returns an empty list unless
DEBUG is true, so it is safe to leave in a production URLconf:
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("catalog/", include("catalog.urls")),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
The equivalent explicit form is re_path(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}) inside an
if settings.DEBUG: block. A test with a temporary MEDIA_ROOT shows both the happy path and the path-traversal defence:
.. (ex/fbv_tests_media.py)@override_settings(DEBUG=True, MEDIA_ROOT=tmp_dir)
def test_media(self):
r = self.client.get("/media/cover.txt")
print("media:", r.status_code, b"".join(r.streaming_content))
self.assertEqual(self.client.get("/media/../settings.py").status_code, 400)
Source: Django: static.serve
media: 200 b'cover'
(The traversal attempt raises SuspiciousFileOperation, a SuspiciousOperation, so it is answered with the 400 error view.) How uploads
get into MEDIA_ROOT and how storage is configured is on File Uploads & Storage;
the static-assets side (collectstatic, STORAGES) is on Static Files & Front-End Assets.
The error views
Django provides four views in django.views.defaults; none of them is used while DEBUG = True (you get the technical debug pages
instead), so test error pages with DEBUG = False.
| View | Triggered by | Template (root of the template tree) |
|---|---|---|
|
|
|
|
An unhandled exception |
|
|
|
|
|
|
|
To change the behaviour, point the module-level handler404, handler500, handler403 and handler400 variables in the root URLconf
at your own views (setting them anywhere else has no effect). The CSRF failure page is replaced through the CSRF_FAILURE_VIEW
setting, not through a handler.
# config/urls.py
handler404 = "catalog.views.custom_404"
handler403 = "catalog.views.custom_403"
handler400 = "catalog.views.custom_400"
handler500 = "catalog.views.custom_500"
# catalog/views.py
from django.views.defaults import bad_request, page_not_found, permission_denied, server_error
def custom_404(request, exception):
return page_not_found(request, exception, template_name="404.html")
def custom_403(request, exception):
return permission_denied(request, exception)
def custom_400(request, exception):
return bad_request(request, exception)
def custom_500(request): # note: no exception argument
return server_error(request)
Wrapping the defaults like this is only a skeleton; a real handler adds logging, a search box or a correlation id. The 404 template used here reads the variable the default view supplies:
<h1>Not found</h1><p>{{ request_path }} does not exist.</p>
Three views that raise the exceptions, and a test with DEBUG = False (a view that raises an unexpected exception normally
re-raises it through the test client, so the client is told not to):
from django.core.exceptions import BadRequest, PermissionDenied
def forbidden(request):
raise PermissionDenied
def bad(request):
raise BadRequest("missing parameter")
def boom(request):
raise RuntimeError("boom")
@override_settings(DEBUG=False)
class ErrorViewTests(TestCase):
def test_error_pages(self):
self.client.raise_request_exception = False
for url in ("/nowhere/", "/catalog/errors/forbidden/", "/catalog/errors/bad/", "/catalog/errors/boom/"):
r = self.client.get(url)
print(r.status_code, r.content)
404 b'<h1>Not found</h1><p>/nowhere/ does not exist.</p>\n'
403 b'<h1>Forbidden</h1>\n'
400 b'<h1>Bad request</h1>\n'
500 b'<h1>Something went wrong</h1>\n'
(The suite prints each status separately; the four lines are the same real output.) In production, an unhandled exception should
also be reported: see Logging & Error Reporting. Keep 500.html static: the
default view renders it with an empty context precisely so it cannot fail in turn.
Testing function-based views
A function-based view is the easiest kind to test: a request goes in, a response comes out. Three levels are common:
-
The test client (
self.client) runs the whole stack (middleware, URL resolution, templates) and returns a response with the extra attributescontext,templates,json()andredirect_chain. Every test on this page uses it. It skips CSRF checks unless you createClient(enforce_csrf_checks=True). -
RequestFactorybuilds a bareHttpRequestyou pass straight to the view function. There is no middleware, so you setrequest.useryourself; it suits tests of one view’s logic and of decorators (see theOrderTestsexample above, which calls the decorated views withRequestFactory().get("/", headers=…)). -
AsyncClientis the async twin of the test client, and Django test cases acceptasync deftest methods.
Use assertContains, assertRedirects and assertTemplateUsed rather than comparing raw bytes, and assertNumQueries to keep a
view’s query count from creeping up. Testing is the subject of Testing.
$ python manage.py test catalog
Found 13 test(s).
System check identified no issues (0 silenced).
Ran 13 tests in 0.140s
OK
Pitfalls checklist
-
Forgetting
return, or returning something that is not anHttpResponse:ValueError("didn’t return an HttpResponse object"). -
Changing state on GET: a link prefetcher, crawler or
<img src>can trigger it. Use POST with@require_POSTand a CSRF token (the reservation view), then redirect. -
@csrf_exemptto "make the form work": the token is missing from the template instead. Add{% csrf_token %}. -
get_object_or_404(Model, pk=pk)on a model whose rows are visible only to their owner: pass a filtered QuerySet (Book.objects.filter(…)) so another user’s id is a 404, not a data leak. -
get_list_or_404()for a search or listing that may legitimately be empty. -
Relying on the
Http404("message")text for visitors: the message is meant for debugging and appears on the debug page whenDEBUGis true; production pages come from your404.html. -
Header decorators below
condition(): the304losesVaryandCache-Control. -
@etagstacked on@last_modified: use onecondition(). -
cache_control(public=True)on a page that depends on the user: shared caches will serve it to everyone; useprivate=True, ornever_cache. -
A decorator written for sync views applied to an
async defview: the wrapper receives a coroutine and no longer awaits the view; branch oninspect.iscoroutinefunction(). -
Sync ORM calls inside an
async defview:SynchronousOnlyOperation. -
Mutable module-level state in views: shared between requests and threads.
-
Testing error pages with
DEBUG = True: the custom handlers are never used. -
Redirecting to a URL read from
request.GET["next"]withouturl_has_allowed_host_and_scheme(): an open redirect.
Where the books are outdated
The four reference books for this section target Django 1.11 to 3.x, and several habits that were common in function-based views are gone or wrong:
| Older practice | Current practice | Changed in |
|---|---|---|
|
|
|
|
|
|
|
|
|
A "add to basket" or "logout" link that changes state on GET |
|
always wrong |
|
Include |
always wrong |
A view that returns the same page unconditionally, with caching left to a reverse proxy |
|
long available; rarely taught |
Views that only work as sync code |
|
3.1 / 5.0 |
A bare |
|
always available |
Source: Django: Deprecation timeline
The conditional-processing decorators, never_cache, vary_on_headers and the built-in error views are not covered by the
books at all, although the official documentation has described them for many releases.
What changed recently
| Version | Change relevant to function-based views |
|---|---|
3.0 |
|
3.1 |
Async views; |
3.2 |
|
4.0 |
|
5.0 |
|
5.1 |
|
5.2 (LTS) |
|
6.0 |
Built-in Content Security Policy support, including the |
6.1 |
|
Source: Django 6.1 release notes