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:

catalog/models.py (ex/fbv_models.py)
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:

catalog/urls.py (excerpt) (ex/fbv_urls.py)
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
]

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.

catalog/views.py (ex/fbv_views_basic.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

The checks pass on the first three routes:

Status codes and content type (ex/fbv_tests_basic.py)
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)

A few rules follow from that contract:

  • The first parameter is the request. It is conventionally called request; it carries method, GET, POST, body, headers, user, session and the rest (see Request/Response Cycle).

  • URL captures arrive as arguments. path("books/<int:pk>/", …​) calls book_detail(request, pk=3); the converter has already turned the text into an int, so no manual parsing is needed.

  • A view must return a response. A function that falls off the end returns None; Django raises ValueError ("The view …​ didn’t return an HttpResponse object. It returned None instead").

  • Status helpers exist for the common codes. HttpResponseNotFound, HttpResponseForbidden, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseGone, HttpResponseServerError and the redirect classes are all in django.http; for any other code pass status= to HttpResponse.

  • 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

django.http.Http404

404

django.views.defaults.page_not_found(), overridable with handler404

django.core.exceptions.PermissionDenied

403

django.views.defaults.permission_denied(), overridable with handler403

django.core.exceptions.BadRequest

400

django.views.defaults.bad_request(), overridable with handler400

django.core.exceptions.SuspiciousOperation (and subclasses)

400

Same as BadRequest, unless a component handled it first (for example by resetting session data)

Any other uncaught exception

500

django.views.defaults.server_error(), overridable with handler500; with DEBUG = True the technical debug page instead

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.

An async view using the async shortcut (ex/fbv_views_async.py)
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]})
The async routes answer like their sync twins (ex/fbv_tests_async.py)
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).

Rendering a list and a detail page (ex/fbv_views_render.py)
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})

The detail template contains a small form; the {% csrf_token %} tag is what lets the POST in redirect() and resolve_url() pass CSRF validation:

catalog/templates/catalog/book_detail.html (ex/fbv_book_detail.html)
<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.

Fetching or answering 404 (ex/fbv_views_404.py)
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]})
404 for a missing row, an unpublished row and an author without books (ex/fbv_tests_404.py)
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:

  1. a model instance: its get_absolute_url() is called;

  2. a view name (or a view callable), with optional positional or keyword arguments, resolved with reverse();

  3. 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

False

False

302

True

False

301

False

True

307

True

True

308

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:

Post/Redirect/Get with a guarded state change (ex/fbv_views_redirect.py)
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

Running the redirects:

Statuses and Location headers (ex/fbv_tests_redirects.py)
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

django.views.decorators.http

require_http_methods(list), require_GET, require_POST, require_safe

Answer 405 Method Not Allowed (an HttpResponseNotAllowed with an Allow header) when the method is not permitted

django.views.decorators.http

condition, etag, last_modified, conditional_page

Conditional GET and conditional update with ETag and Last-Modified (see Conditional view processing)

django.views.decorators.cache

cache_control(**kwargs), never_cache

Patch Cache-Control; forbid caching

django.views.decorators.vary

vary_on_headers(*headers), vary_on_cookie

Set the Vary header

django.views.decorators.gzip

gzip_page

Compress the body when the client sends Accept-Encoding: gzip

django.views.decorators.common

no_append_slash

Exclude a view from APPEND_SLASH redirects (3.2)

django.views.decorators.csrf

csrf_exempt, csrf_protect, requires_csrf_token, ensure_csrf_cookie

Per-view control over CsrfViewMiddleware

django.views.decorators.clickjacking

xframe_options_deny, xframe_options_sameorigin, xframe_options_exempt

Per-view X-Frame-Options

django.views.decorators.csp

csp_override(config), csp_report_only_override(config)

Per-view Content-Security-Policy (6.0)

django.views.decorators.debug

sensitive_variables(*names), sensitive_post_parameters(*names)

Hide values in error reports

django.contrib.auth.decorators

login_required, login_not_required (5.1), user_passes_test, permission_required

Gate a view on the user

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:

Method restrictions (ex/fbv_views_methods.py)
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 to Cache-Control (max_age=60, public=True, private=True, no_cache=True, s_maxage=…​, must_revalidate=True, and so on; underscores become hyphens).

  • never_cache adds Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private and an Expires header; 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 to Vary, telling caches to key on those request headers; vary_on_cookie is the shortcut for Cookie. Without it a shared cache could serve one user’s French page to another user.

Combining the decorators (ex/fbv_views_cache.py)
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.

Compressing one view (ex/fbv_views_gzip.py)
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.

What the middleware does to the reservation view (ex/fbv_tests_csrf.py)
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
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.

Requiring a login (ex/fbv_views_login.py)
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 a 304 returned by condition() 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):

A decorator that works on sync and async views (ex/fbv_decorator.py)
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.

The middleware on the health view (ex/fbv_tests_conditional_middleware.py)
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)
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) or None when the resource does not exist;

  • last_modified_func: a datetime (timezone-aware when USE_TZ = True) or None.

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:

A conditional detail endpoint with an optimistic-locking PUT (ex/fbv_views_conditional.py)
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:

Fetch, revalidate, and conflict (ex/fbv_tests_conditional.py)
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:

Why vary_on_headers goes above condition (ex/fbv_tests_order.py)
@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:

Serving MEDIA_ROOT while DEBUG is on (ex/fbv_urls_static.py)
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:

serve() returns the file and refuses .. (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)
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)

page_not_found(request, exception, template_name="404.html")

Http404, or a URL that matches no pattern

404.html; context has request_path and exception; rendered with a RequestContext, so context processors apply

server_error(request, template_name="500.html")

An unhandled exception

500.html; rendered with an empty context to reduce the chance of a second failure

permission_denied(request, exception, template_name="403.html")

PermissionDenied

403.html; context has exception; without the template, plain "403 Forbidden"

bad_request(request, exception, template_name="400.html")

BadRequest, SuspiciousOperation

400.html; the exception is deliberately not in the context, because its message may contain paths or other sensitive details

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.

Handlers in the root URLconf and views that wrap the defaults (ex/fbv_handlers.py)
# 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:

catalog/templates/404.html (ex/fbv_404.html)
<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):

Raising the exceptions behind each handler (ex/fbv_views_errors.py)
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")
Testing the error pages (ex/fbv_tests_errors.py)
@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 attributes context, templates, json() and redirect_chain. Every test on this page uses it. It skips CSRF checks unless you create Client(enforce_csrf_checks=True).

  • RequestFactory builds a bare HttpRequest you pass straight to the view function. There is no middleware, so you set request.user yourself; it suits tests of one view’s logic and of decorators (see the OrderTests example above, which calls the decorated views with RequestFactory().get("/", headers=…​)).

  • AsyncClient is the async twin of the test client, and Django test cases accept async def test 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 an HttpResponse: 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_POST and a CSRF token (the reservation view), then redirect.

  • @csrf_exempt to "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 when DEBUG is true; production pages come from your 404.html.

  • Header decorators below condition(): the 304 loses Vary and Cache-Control.

  • @etag stacked on @last_modified: use one condition().

  • cache_control(public=True) on a page that depends on the user: shared caches will serve it to everyone; use private=True, or never_cache.

  • A decorator written for sync views applied to an async def view: the wrapper receives a coroutine and no longer awaits the view; branch on inspect.iscoroutinefunction().

  • Sync ORM calls inside an async def view: 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"] without url_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

from django.shortcuts import render_to_response, context_instance=RequestContext(request)

render(request, template, context)

render_to_response() removed in 3.0

from django.conf.urls import url with regex routes for every view

path() with converters; re_path() for regexes (see URLs & Routing)

url() removed in 4.0

if request.is_ajax(): to branch between HTML and JSON

request.headers.get("x-requested-with") or, better, request.accepts("application/json")

HttpRequest.is_ajax() removed in 4.0

A "add to basket" or "logout" link that changes state on GET

@require_POST, CSRF token, Post/Redirect/Get (an erratum, not just old age)

always wrong

@csrf_exempt on form views to silence 403s, or a CsrfExemptMixin

Include {% csrf_token %} or send X-CSRFToken; exempt only signature-verified webhooks

always wrong

A view that returns the same page unconditionally, with caching left to a reverse proxy

condition/etag/last_modified for cheap 304`s and `412 protection on updates

long available; rarely taught

Views that only work as sync code

async def views, aget_object_or_404(), async-aware decorators

3.1 / 5.0

A bare except Model.DoesNotExist: raise Http404 repeated in every view

get_object_or_404() with a scoped QuerySet

always available

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

render_to_response() removed; never_cache() and add_never_cache_headers() add the private directive.

3.1

Async views; ConditionalGetMiddleware stops adding ETag to empty responses; cache_control() supports several field names in the no-cache directive.

3.2

no_append_slash() decorator.

4.0

HttpRequest.is_ajax() and url() removed; AdminSite no longer wraps its index view with never_cache when accessed directly.

5.0

cache_control, never_cache, csrf_*, gzip_page, condition, etag, last_modified, conditional_page, require_*, vary_on_*, sensitive_* and xframe_options_* decorators support async views; aget_object_or_404() and aget_list_or_404() added.

5.1

login_required, permission_required and user_passes_test support async views; login_not_required() and LoginRequiredMiddleware.

5.2 (LTS)

redirect() and HttpResponseRedirect get preserve_request (302/307, 301/308); method_decorator supports async methods.

6.0

Built-in Content Security Policy support, including the csp_override() and csp_report_only_override() view decorators.

6.1

redirect() and the redirect response classes accept max_length; RedirectView.preserve_request; page keys for caches that vary on headers change.