Class-Based Views

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. Every listing below was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, 43 tests (manage.py test), curl sessions against runserver (WSGI) and against Uvicorn (ASGI), and the shell examples all passed, and the output shown is the real output. None of the code needs anything but SQLite.

A class-based view (CBV) is a Python class that answers an HTTP request with one method per HTTP verb instead of one function with an if request.method == …​ ladder. Django does not call the class: the URL resolver needs a callable that takes a request, so as_view() builds one. Everything else on this page follows from that one trick: the per-request instance, the setup() and dispatch() hooks, configuration through class attributes and as_view() keyword arguments, decorators that need an adapter, mixins that depend on the method resolution order, and async handlers that are detected from the class. This page covers the base classes in django.views and django.views.generic.base (View, TemplateView, RedirectView) and the mixin machinery they are built from. The model-aware generic views (ListView, DetailView, FormView, CreateView, UpdateView, DeleteView and the date-based views) are built on the same machinery and get their own page, Generic Views. The function-based alternative is on Function-Based Views & Shortcuts, and the pipeline around every view is on Request/Response Cycle and Middleware. For the models used below see Models & Fields; for the Python side of multiple inheritance see Advanced OOP & Design and, for decorators, Decorators & Metaclasses.

What a class-based view is

The contract Django has with every view is the same, class-based or not: a callable that receives an HttpRequest (plus whatever the URL pattern captured) and returns an HttpResponse, or raises an exception that Django turns into one. A CBV keeps that contract and wraps it. The callable that goes into the URLconf is the function returned by Class.as_view(); on every request that function builds a fresh instance of your class, hands it the request, and lets dispatch() pick the method that matches the HTTP verb.

flowchart TD U["URLconf match
path('greet/', GreetingView.as_view())"] --> V["view(request, *args, **kwargs)
the function that as_view() returned"] V --> I["self = cls(**initkwargs)
new instance for this request"] I --> S["self.setup(request, *args, **kwargs)
sets request, args, kwargs; head = get"] S --> C{"self.request
was set?"} C -->|"no"| E["AttributeError
(setup() override forgot super())"] C -->|"yes"| D["self.dispatch(request, *args, **kwargs)"] D --> M{"request.method.lower()
in http_method_names?"} M -->|"no"| N["http_method_not_allowed()
405 and an Allow header"] M -->|"yes"| H{"getattr(self, method)
exists?"} H -->|"no"| N H -->|"yes"| G["handler(request, *args, **kwargs)
get, post, options, ..."] G --> R["HttpResponse or
TemplateResponse"]

The diagram is the whole mechanism of django.views.generic.base.View; the rest of the page fills in each box. Three consequences are worth holding on to before the details:

  • There is one instance per request. Instance attributes you set in setup(), dispatch() or a handler disappear with the request, so they are safe. Class attributes, and the values you pass to as_view(), are created once when the URLconf is imported and are shared by every request the process serves.

  • The handler methods are ordinary methods. get(), post() and the rest receive request as an argument and return a response exactly like a function view; they can also read self.request, self.args and self.kwargs. Shortcuts such as render() and redirect() and response classes such as TemplateResponse work unchanged inside a CBV.

  • Anything that wraps a view sees the function, not the class. Middleware, decorators in the URLconf and the resolver all receive the function from as_view(). It carries two attributes, view_class and view_initkwargs, for code that needs the class back.

The Bookshelf models used on this page

The examples serve the Bookshelf catalogue from an app called catalog. Only two models are needed here; the larger model set with genres, reviews and a custom user is on Models & Fields and Relationships. The visits counter and Meta.ordering are used by later examples:

catalog/models.py
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=200)

    def __str__(self):
        return self.name


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)
    visits = models.PositiveIntegerField(default=0)
    is_published = models.BooleanField(default=True)

    class Meta:
        ordering = ["title"]

    def __str__(self):
        return self.title

Each view lives in a module named for its topic (views_basic.py, views_template.py, views_redirect.py, views_decorated.py, views_mixins.py, views_async.py) and all of them are wired in one catalog/urls.py, shown in full near the end. The project’s root URLconf simply does path("", include("catalog.urls")). For the structure of projects and apps see Project Structure & Apps.

View: one method per HTTP verb

django.views.View (importable from django.views and django.views.generic) is the parent of every class-based view. It implements exactly four things: dispatch by HTTP method, a 405 response for methods you did not write, a default OPTIONS answer, and sanity checking in as_view(). A view is a subclass with methods named after the verbs it supports, written in lowercase.

catalog/views_basic.py: the smallest useful view
from django.http import HttpResponse
from django.views import View


class GreetingView(View):
    greeting = "Good day"

    def get(self, request, *args, **kwargs):
        name = kwargs.get("name", "reader")
        return HttpResponse(f"{self.greeting}, {name}!")

    def post(self, request, *args, **kwargs):
        return HttpResponse("Message received", status=201)
catalog/urls.py: the same class mounted twice with different configuration
from django.urls import path

from . import views_basic

urlpatterns = [
    path("greet/", views_basic.GreetingView.as_view(), name="greet"),
    path("greet/<str:name>/", views_basic.GreetingView.as_view(greeting="Welcome"), name="greet-named"),
]

A curl session against runserver, and a shell session with Django’s test client for the verbs curl cannot exercise without a CSRF token (see the paragraph on CSRF below), shows what the base class does for you:

GET and HEAD answer from get(); OPTIONS and an unsupported verb are answered by View itself (some response headers trimmed)
$ curl -i localhost:8723/greet/Ada/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 13
X-View: catalog.views_basic.GreetingView

Welcome, Ada!

>>> c = Client()
>>> r = c.delete("/greet/");  r.status_code, r["Allow"]
(405, 'GET, POST, HEAD, OPTIONS')
>>> r = c.options("/greet/"); r.status_code, r["Allow"], r["Content-Length"]
(200, 'GET, POST, HEAD, OPTIONS', '0')
>>> c.head("/greet/").status_code
200

Four behaviours are worth knowing exactly:

  • HEAD is GET without a body. setup() assigns self.head = self.get when the class defines get() but no head(). Define head() yourself only when you can answer cheaper than running the whole get().

  • OPTIONS is built in. View.options() returns an empty 200 response whose Allow header lists every handler the instance has (GET, POST, HEAD, OPTIONS above; HEAD appears because setup() created it). CORS preflights are a different matter and are normally answered by middleware before the view runs; see CORS.

  • An unsupported verb is a 405, not an error. http_method_not_allowed() returns HttpResponseNotAllowed with the same Allow list, an empty body, and writes a warning to the django.request logger (Method Not Allowed (DELETE): /greet/ on the console).

  • Handlers receive the URL’s captures. Named groups arrive as keyword arguments (kwargs["name"]), unnamed groups as positional arguments, and both are also kept on self.args and self.kwargs. Declare *args, **kwargs on handlers you intend to subclass so a future URL change does not break them.

Source: View

http_method_names: the allow-list

View.http_method_names is the complete list of verbs that may reach a handler. The default is ["get", "post", "put", "patch", "delete", "head", "options", "trace"]. dispatch() lowercases request.method and refuses anything that is not in the list, even when a method of that name exists on the class. That makes the attribute a cheap way to forbid verbs for a whole family of views, and it is also the reason an unusual verb (WebDAV’s PROPFIND, say) is a 405 until you add its lowercase name to the list and write a method for it. Removing "options" does not remove the method, it only makes the verb a 405; and trace should stay off unless you know why you need it.

The TimedView below is the base for the next two sections. It narrows http_method_names, assigns per-request state in setup() and decorates every response in dispatch():

catalog/views_basic.py: a base class that restricts verbs and adds headers to every response
import time
import uuid

from django.http import JsonResponse
from django.views import View

from .models import Book


class TimedView(View):
    """Cross-cutting behaviour in setup() and dispatch(); subclasses add handlers."""

    http_method_names = ["get", "post", "head", "options"]  # no PUT/PATCH/DELETE/TRACE

    def setup(self, request, *args, **kwargs):
        super().setup(request, *args, **kwargs)  # sets self.request, self.args, self.kwargs
        self.request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
        self.started = time.perf_counter()

    def dispatch(self, request, *args, **kwargs):
        response = super().dispatch(request, *args, **kwargs)
        response["X-Request-ID"] = self.request_id
        response["X-Elapsed-Ms"] = f"{(time.perf_counter() - self.started) * 1000:.1f}"
        return response


class BookCountView(TimedView):
    def get(self, request, *args, **kwargs):
        return JsonResponse({"books": Book.objects.filter(is_published=True).count()})
$ curl -i localhost:8723/book-count/
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 6407e882957240abbcc75d18c373c91f
X-Elapsed-Ms: 0.7
Content-Length: 12

{"books": 3}

>>> c.put("/book-count/").status_code, c.put("/book-count/")["Allow"]
(405, 'GET, HEAD, OPTIONS')

as_view(): configuration, validation and per-request instances

as_view(*initkwargs) is a class method (decorated classonlymethod, so it cannot be called on an instance) that returns the view function. What it does at *import time, when the URLconf is loaded, and what that function does at request time are different things, and mixing them up is the source of most CBV bugs.

At import time as_view() validates the keyword arguments you passed and builds the function:

  • a keyword that is the name of an HTTP method (as_view(get=…​)) is a TypeError;

  • a keyword that is not already an attribute of the class (as_view(colour="red")) is a TypeError, which catches typos in the URLconf the moment the server starts;

  • the function gets view_class, view_initkwargs, the class’s docstring, and a copy of everything in cls.dispatch.dict, which is how decorators that set an attribute on the function (csrf_exempt sets csrf_exempt = True) keep working when you decorate dispatch();

  • when every HTTP handler on the class is async def, the function is marked as a coroutine function (see Async class-based views).

At request time the function runs the sequence in the diagram: self = cls(initkwargs) (View.init simply does setattr for each keyword), self.setup(request, *args, kwargs), a check that setup() really set self.request, and finally return self.dispatch(request, *args, **kwargs).

Validation, view_class and view_initkwargs in a shell
from django.views import View
from django.views.generic import RedirectView, TemplateView

from catalog.views_basic import GreetingView
from catalog.views_mixins import StaffDashboardView

for cls in (View, TemplateView, RedirectView, StaffDashboardView):
    print(cls.__name__, "->", " > ".join(c.__name__ for c in cls.__mro__))

view = GreetingView.as_view(greeting="Hi")
print(type(view).__name__, view.__name__, view.view_class.__name__, view.view_initkwargs)

for bad in ({"get": print}, {"colour": "red"}):
    try:
        GreetingView.as_view(**bad)
    except TypeError as exc:
        print("TypeError:", exc)
print(GreetingView.view_is_async)
View -> View > object
TemplateView -> TemplateView > TemplateResponseMixin > ContextMixin > View > object
RedirectView -> RedirectView > View > object
StaffDashboardView -> StaffDashboardView > AuditMixin > StaffOnlyMixin > CurrentYearMixin > TemplateView > TemplateResponseMixin > ContextMixin > View > object
function view GreetingView {'greeting': 'Hi'}
TypeError: The method name get is not accepted as a keyword argument to GreetingView().
TypeError: GreetingView() received an invalid keyword 'colour'. as_view only accepts arguments that are already attributes of the class.
False

Two configuration styles result, and they combine: subclass and override attributes (class MorningGreeting(GreetingView): greeting = "Morning") or pass attributes to as_view() in the URLconf, as the second path() above did. Use as_view() keywords for deployment-specific variations of one class (a different template_name at two URLs) and subclasses when the variation deserves a name, tests, or added methods.

Class state is shared, instance state is not

Because as_view() runs once while the instance is created for every request, a mutable class attribute is one object shared by all requests of the process, across threads. The classic mistake looks harmless:

catalog/views_basic.py: a shared list versus per-request state
from django.http import HttpResponse
from django.views import View


class SharedStateView(View):
    seen = []  # BUG: one list shared by every request served by this process

    def get(self, request, *args, **kwargs):
        self.seen.append(request.path)
        return HttpResponse(f"{len(self.seen)} requests seen")


class PerRequestStateView(View):
    def setup(self, request, *args, **kwargs):
        super().setup(request, *args, **kwargs)
        self.seen = []  # a fresh list on a fresh instance, for every request

    def get(self, request, *args, **kwargs):
        self.seen.append(request.path)
        return HttpResponse(f"{len(self.seen)} requests seen")
Three requests to each view (a running dev server answers 1, 2, 3 for the first and 1, 1, 1 for the second)
$ curl -s localhost:8723/shared/ localhost:8723/shared/ localhost:8723/shared/
1 requests seen2 requests seen3 requests seen
$ curl -s localhost:8723/per-request/ localhost:8723/per-request/ localhost:8723/per-request/
1 requests seen1 requests seen1 requests seen

SharedStateView leaks one request’s data into the next and is a data race under a threaded server. The same applies to values passed through as_view(): as_view(items=[]) creates one list when the URLconf is imported. Initialise mutable state in setup() (or init, which receives the as_view() keywords) and keep anything that must outlive a request in the database, the cache or a module-level object designed for concurrency.

Source: View.as_view()

setup() and dispatch(): the two hooks

setup(request, args, **kwargs) initialises per-request attributes: it sets self.request, self.args and self.kwargs and creates the head alias. It runs before dispatch(), so everything there can rely on those attributes, including dispatch() overrides and mixins. Override setup() to compute values every handler needs (a request ID, the parsed tenant, a service object) and *always call super().setup(…​) first. Forgetting it is caught immediately, because as_view() checks for self.request afterwards:

catalog/views_basic.py: a setup() that forgot super()
import time

from django.http import HttpResponse
from django.views import View


class ForgetfulView(View):
    def setup(self, request, *args, **kwargs):
        self.started = time.perf_counter()  # forgot super().setup(...)

    def get(self, request, *args, **kwargs):
        return HttpResponse("never reached")
>>> Client().get("/forgetful/")
AttributeError: ForgetfulView instance has no 'request' attribute. Did you override setup() and forget to call super()?

dispatch(request, *args, kwargs) is "the view part of the view". Its default body is four lines: lowercase the method, check it against http_method_names, look the handler up with getattr(self, method, self.http_method_not_allowed), and call it. Override it for behaviour that is the same for every verb: authentication and permission checks (that is exactly what LoginRequiredMixin does), logging, response headers (as TimedView did), tenant resolution, or rejecting a request before any handler runs. Three rules keep an override correct: call super().dispatch(request, *args, kwargs) so the verb routing still happens; return the response (or a modified one) rather than discarding it; and do not assume self.request differs from the request argument, they are the same object.

dispatch() is also where a view’s decorators are applied (next sections), and where the sync/async distinction bites: an async def handler makes super().dispatch() return a coroutine, so an overriding dispatch() that post-processes the response must itself be async def and await it.

Where a CBV sits relative to CSRF and middleware

dispatch() runs inside the view, which is after the middleware’s process_view() hooks. For unsafe methods, CsrfViewMiddleware therefore answers before http_method_not_allowed() ever gets a chance: a bare curl -X DELETE localhost:8723/greet/ returns 403 Forbidden (no CSRF token), not 405, while the test client, which skips CSRF checks by default, shows the 405. Both behaviours are correct and the order matters when you debug a "wrong status code": 403 from the CSRF middleware is reported before the 405 from the view. The full pipeline is covered on Request/Response Cycle.

TemplateView: render a template with the URL’s captures

TemplateView is View plus two mixins, TemplateResponseMixin (which knows how to render) and ContextMixin (which knows how to build the context). Its MRO, printed above, is TemplateView > TemplateResponseMixin > ContextMixin > View. Its only handler is get(), which is three lines: build the context from the URL’s keyword arguments with get_context_data(**kwargs), then render_to_response(context). That makes it the right base for any page that shows a template and nothing else: a home page, an "about" page, a documentation page, the page that hosts a client-side app.

flowchart LR G["TemplateView.get(request, *args, **kwargs)"] --> CD["get_context_data(**kwargs)
your override first, then ContextMixin"] CD -->|"kwargs + 'view' + extra_context"| RR["render_to_response(context)
TemplateResponseMixin"] RR --> TN["get_template_names()
[template_name] by default"] TN --> TR["TemplateResponse(request, templates, context)
lazy: not rendered yet"] TR --> MW["template-response middleware,
then response.render()"]
catalog/views_template.py: attributes plus a get_context_data() override
from django.views.generic import TemplateView

from .models import Book


class HomeView(TemplateView):
    template_name = "catalog/home.html"
    extra_context = {"site_name": "Bookshelf"}

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["latest_books"] = Book.objects.filter(is_published=True).order_by("-id")[:3]
        return context
catalog/urls.py: a template-only page needs no view class at all
from django.views.generic import TemplateView

urlpatterns = [
    path("", views_template.HomeView.as_view(), name="home"),
    path(
        "about/",
        TemplateView.as_view(template_name="catalog/about.html", extra_context={"site_name": "Bookshelf"}),
        name="about",
    ),
]
<h1>{{ site_name }}</h1>
<ul>
{% for book in latest_books %}  <li>{{ book.title }}</li>
{% endfor %}</ul>
$ curl -s localhost:8723/
<h1>Bookshelf</h1>
<ul>
  <li>The Dispossessed</li>
  <li>Lavinia</li>
  <li>Earthsea</li>
</ul>

What goes into the context, in order, is: the URL’s named captures (kwargs), the view itself under the key view (set by ContextMixin with setdefault, so a URL capture named view would win), then everything in extra_context. extra_context is a class attribute (also accepted by as_view()) holding a dictionary of constants; it is evaluated once at import time, so never put a QuerySet or a "current time" in it. Anything per-request, such as the three latest books above, belongs in get_context_data(), and the override must call super().get_context_data(**kwargs) first and return the dictionary, which is what lets mixins cooperate (see the MRO section).

Choosing the template and the response

TemplateResponseMixin exposes four attributes and two methods:

Member What it does

template_name

The template to render. Required unless get_template_names() is overridden, otherwise ImproperlyConfigured is raised.

get_template_names()

Returns a list of candidate names; the first one that exists is used. Override it to pick a template from request or URL data.

template_engine

Name of a configured template engine (the NAME of an entry in TEMPLATES); None tries all engines in order.

response_class

The response type, TemplateResponse by default. Replace it with a subclass to change rendering or headers.

content_type

The Content-Type, passed to the response; useful for text/plain files such as robots.txt.

render_to_response(context, **response_kwargs)

Builds response_class(request=…​, template=get_template_names(), context=context, using=template_engine, **kwargs).

catalog/views_template.py: a template chosen from the URL, with a fallback, and a plain-text template
from django.views.generic import TemplateView


class PageView(TemplateView):
    """/pages/<slug>/ renders catalog/pages/<slug>.html, or default.html if absent."""

    def get_template_names(self):
        return [f"catalog/pages/{self.kwargs['slug']}.html", "catalog/pages/default.html"]


class RobotsView(TemplateView):
    template_name = "catalog/robots.txt"
    content_type = "text/plain"
catalog/urls.py
urlpatterns += [
    path("pages/<slug:slug>/", views_template.PageView.as_view(), name="page"),
    path("robots.txt", views_template.RobotsView.as_view(), name="robots"),
]
$ curl -s localhost:8723/pages/shipping/
<h1>Shipping</h1><p>Orders ship in two working days.</p>
$ curl -si localhost:8723/robots.txt | grep -i content-type
Content-Type: text/plain

/pages/unknown/ falls through to catalog/pages/default.html, and the URL’s slug is available in that template because it was a URL capture. Because the response is a TemplateResponse, rendering is lazy: the view returns an unrendered response, middleware and decorators may still change response.context_data or template_name, and Django calls response.render() just before the response leaves the handler. In tests the same property lets you assert on the context without parsing HTML (response.context_data, see the testing section). TemplateView answers only GET (and HEAD, OPTIONS): a POST to a TemplateView is a 405.

RedirectView: redirect on any method

RedirectView is a View whose every handler (get, head, post, options, delete, put, patch) calls the same redirecting get(). Pointing a URL at it replaces a function view that only returns redirect(…​), and it is the usual tool for moved pages, short links and canonical-URL redirects. It has five attributes, all settable on the class or through as_view():

Attribute Default Meaning

url

None

The target as a string. Dictionary-style % formatting is applied with the URL’s named captures ("/books/%(pk)s/"), and it is always applied, so a literal percent sign must be written %%. None (with no pattern_name) makes the view answer 410 Gone.

pattern_name

None

A URL name to reverse(), using the same positional and keyword arguments the request captured. Ignored when url is set.

permanent

False

True answers 301 instead of 302. Browsers and search engines cache a 301, so use it only for moves you will never undo.

query_string

False

True appends the request’s query string to the target (with & if the target already has one).

preserve_request

False

New in Django 6.1. True keeps the HTTP method and body across the redirect: 307 instead of 302, or 308 instead of 301 when permanent is True.

catalog/urls.py: every attribute at least once
from django.views.generic import RedirectView

urlpatterns += [
    path("old-catalogue/", RedirectView.as_view(pattern_name="book-list", permanent=True, query_string=True)),
    path("legacy/<int:pk>/", RedirectView.as_view(url="/books/%(pk)s/", permanent=True)),
    path("docs/", RedirectView.as_view(url="https://docs.djangoproject.com/en/6.1/")),
    path("retired/", RedirectView.as_view(url=None), name="retired"),
    path(
        "old-order/",
        RedirectView.as_view(pattern_name="greet", permanent=True, preserve_request=True),
        name="old-order",
    ),
]
$ curl -si "localhost:8723/old-catalogue/?q=x" | grep -i "^HTTP\|^Location"
HTTP/1.1 301 Moved Permanently
Location: /books/?q=x
$ curl -si localhost:8723/legacy/2/ | grep -i "^HTTP\|^Location"
HTTP/1.1 301 Moved Permanently
Location: /books/2/
$ curl -si localhost:8723/retired/ | head -1
HTTP/1.1 410 Gone

>>> c.post("/old-order/").status_code     # permanent + preserve_request
308
>>> c.post("/docs/").status_code          # the default: browsers retry the POST as a GET
302

Why preserve_request exists. With a 301 or 302, browsers may (and for POST historically do) replay the redirected request as a GET and drop the body, which silently breaks a form or API client posting to a moved endpoint. 307 and 308 forbid that change. Before 6.1 you had to subclass RedirectView and build the 307 or 308 response yourself. The response classes HttpResponseRedirect and HttpResponsePermanentRedirect also take a preserve_request argument (documented with the request and response objects), and RedirectView passes its attribute to them. On Django 5.2 LTS keep the subclass.

get_redirect_url(): computed targets

get_redirect_url(*args, **kwargs) is the one method worth overriding. It receives the URL’s captures, must return a URL string or None (410), and the default implementation is the logic of the table above, so calling super().get_redirect_url(…​) after your own work reuses url, pattern_name and query_string. Two patterns come up constantly: side effects before the redirect (a counter, a log line) and computing the target.

catalog/views_redirect.py: count a visit, then redirect by pattern name
from django.db.models import F
from django.shortcuts import get_object_or_404
from django.views.generic import RedirectView

from .models import Book


class BookShortLinkView(RedirectView):
    """/b/<isbn>/ counts a visit, then redirects to the book's detail URL."""

    permanent = False
    query_string = True
    pattern_name = "book-detail"

    def get_redirect_url(self, *args, **kwargs):
        book = get_object_or_404(Book, isbn=kwargs["isbn"])
        Book.objects.filter(pk=book.pk).update(visits=F("visits") + 1)
        return super().get_redirect_url(pk=book.pk)
$ curl -si "localhost:8723/b/9780000000001/?ref=mail" | grep -i "^HTTP\|^Location"
HTTP/1.1 302 Found
Location: /books/2/?ref=mail
$ curl -s localhost:8723/books/2/
{"id": 2, "title": "Lavinia", "visits": 1}

The counter uses F("visits") + 1 inside update(), so the increment is one atomic SQL statement instead of a read-modify-write race; see Making Queries. Counting on a redirect that crawlers and prefetchers follow also counts them, so it is a statistic, not a billing record.

Never redirect to an unchecked user-supplied URL. A view that sends the browser to whatever ?next= says is an open redirect that phishing links abuse. Django’s own LoginView and set_language() validate the next parameter for you, so reuse them where they fit; the helper they use internally is not part of the documented public API (the 3.0 and 4.2 release notes call it private), so do not build on it. For your own redirects the dependable rule is to accept a name, not a URL: map a small set of allowed keys to URL names and reverse() the result.

catalog/views_redirect.py: a redirect that cannot leave the site
from django.urls import reverse
from django.views.generic import RedirectView


class SectionRedirectView(RedirectView):
    """/go/?to=books redirects to a URL *name* picked from a fixed allow-list, never to a raw URL."""

    sections = {"books": "book-list", "home": "home", "about": "about"}

    def get_redirect_url(self, *args, **kwargs):
        name = self.sections.get(self.request.GET.get("to", ""), "home")
        return reverse(name)
>>> c.get("/go/?to=books")["Location"]
'/books/'
>>> [c.get("/go/", {"to": u})["Location"] for u in ("https://evil.example/", "//evil.example/", "javascript:alert(1)", "")]
['/', '/', '/', '/']

Decorating class-based views

Function decorators expect a function that takes request first. A CBV is not that, and its methods take self first, so there are two places to decorate and one adapter.

In the URLconf you decorate the function that as_view() returns. This applies the decorator to that one URL only, and it is the simplest way to protect a built-in view you do not subclass:

from django.contrib.auth.decorators import login_required, permission_required
from django.views.generic import TemplateView

urlpatterns = [
    path("about/", login_required(TemplateView.as_view(template_name="secret.html"))),
    path("vote/", permission_required("polls.can_vote")(VoteView.as_view())),
]

On the class you decorate dispatch(), so that every URL that uses the class gets the behaviour. method_decorator() turns a function decorator into a method decorator by supplying the missing self. It can wrap the method directly or, more compactly, be applied to the class with the name of the method; it also accepts a list or tuple of decorators, applied in the order given (the first one in the list sees the request first):

catalog/views_decorated.py: class-level decorators, in both spellings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView


@method_decorator([never_cache, login_required], name="dispatch")
class PrivateShelfView(TemplateView):
    template_name = "catalog/pages/default.html"


class AccountView(View):
    @method_decorator(never_cache)
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

    def get(self, request, *args, **kwargs):
        return HttpResponse("account")
>>> r = c.get("/shelf/"); r.status_code, r["Location"]
(302, '/accounts/login/?next=/shelf/')
>>> c.force_login(user)
>>> r = c.get("/shelf/"); r.status_code, r["Cache-Control"]
(200, 'max-age=0, no-cache, no-store, must-revalidate, private')

Because as_view() copies the attributes of dispatch onto the returned function, decorators that mark a view by setting an attribute work from the class too. csrf_exempt is the standard example and it is a good one to understand, because exempting a view removes a protection:

catalog/views_decorated.py: a webhook exempt from CSRF, authenticated by a shared secret instead
import hmac
import os

from django.http import HttpResponse, HttpResponseForbidden
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt


@method_decorator(csrf_exempt, name="dispatch")
class StockWebhookView(View):
    """Server-to-server callback: no browser, so no CSRF cookie; authenticated by a shared secret instead."""

    def post(self, request, *args, **kwargs):
        secret = os.environ.get("STOCK_WEBHOOK_SECRET", "")
        supplied = request.headers.get("X-Webhook-Secret", "")
        if not secret or not hmac.compare_digest(secret, supplied):
            return HttpResponseForbidden()
        return HttpResponse(status=204)


class FormPostView(View):
    """Same POST handler, but still protected by CsrfViewMiddleware."""

    def post(self, request, *args, **kwargs):
        return HttpResponse(status=204)
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8724/hooks/stock/
403
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "X-Webhook-Secret: s3cret" localhost:8724/hooks/stock/
204
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8724/form-post/
403

The secret comes from the environment (STOCK_WEBHOOK_SECRET=s3cret was set when this server started), is compared with hmac.compare_digest() to avoid timing leaks, and the request is refused when the secret is unset or missing. Only exempt endpoints called by machines; a view that a browser submits (FormPostView) must keep CSRF protection, as the third request shows. See Settings & Configuration for reading secrets from the environment.

Four details trip people up:

  • method_decorator passes args and *kwargs through, so the decorated method must accept the arguments dispatch() receives; a method with a narrower signature raises TypeError.

  • Decorate dispatch(), not get(), to cover every verb; decorate a single handler (@method_decorator(cache_page(60), name="get")) to cover only that verb.

  • A list passed to method_decorator() is applied in order; stacking several @method_decorator lines applies the bottom one first, as with ordinary stacked decorators.

  • method_decorator() wraps asynchronous methods too since Django 5.2, but decorating dispatch() of a view with async handlers requires an async def dispatch() (see the async section).

For access control the classic alternative to decorators is a mixin: LoginRequiredMixin, PermissionRequiredMixin and UserPassesTestMixin from django.contrib.auth.mixins override dispatch() and call handle_no_permission() on failure, which redirects anonymous users to the login URL and raises PermissionDenied (a 403) for signed-in ones. They are configured with attributes (login_url, permission_required, raise_exception, redirect_field_name) rather than decorator arguments, and the result is equivalent to method_decorator(login_required, name="dispatch"). If every page except a few needs a login, Django 5.1 added LoginRequiredMiddleware with a login_not_required() decorator for the exceptions; note the middleware reads login_url from the login_required() decorator but not from LoginRequiredMixin.

Mixins and the method resolution order

A mixin is a small class that adds one capability and is not meant to stand alone; a CBV combines it with a base view by multiple inheritance. Django’s own views are built exactly this way, which is why TemplateView is little more than View plus two mixins. To predict what a combination does you need one Python rule, the method resolution order (MRO): when self.method() is called, Python searches the classes of type(self).mro from left to right and runs the first definition it finds, and super() means "the next class after this one in that list", not "my parent". The list is computed by C3 linearisation: each class precedes its bases, and the left-to-right order of the bases in the class statement is preserved. Every Django view has View near the end and object last; listing the bases is therefore the way you choose the order in which the hooks run.

flowchart LR A["StaffDashboardView
(your class)"] --> B["AuditMixin
dispatch()"] B -->|"super()"| C["StaffOnlyMixin
dispatch()"] C -->|"super()"| D["CurrentYearMixin
get_context_data()"] D --> E["TemplateView"] E --> F["TemplateResponseMixin
render_to_response()"] F --> G["ContextMixin
get_context_data()"] G --> H["View
setup(), dispatch()"] H --> I["object"]

The arrows are the MRO printed earlier in the shell session for StaffDashboardView. Reading it is the debugging technique for every "why did my override not run" question: print MyView.mro, then find the first class that defines the method.

Four rules make mixins work.

  1. Mixins go to the left of the view class. class V(MyMixin, TemplateView). The base view’s dispatch() does not call super().dispatch(); it routes to a handler and stops. A mixin placed after it in the bases is behind that wall and its dispatch() is never called (the broken example below).

  2. Every override calls super(). A dispatch() or get_context_data() that forgets super() silently disables everything to its right in the MRO.

  3. Mixins stay small and independent. A mixin that uses attributes defined by another class (self.object, self.request.user) documents the dependency or checks for it; a mixin with init arguments is almost always a design smell, because as_view() configures views by attributes.

  4. Only one parent may be a view. Of the bases, only one class should derive from View; the rest are mixins. Combining two generic views (say ListView with ProcessFormView) mixes two get() implementations and rarely works.

A response-format mixin

Rendering is the part of a CBV most worth swapping, and the template pipeline gives you a clean seam: render_to_response(). A mixin adds render_to_json_response(), and the concrete view decides which renderer to call. One gotcha deserves the comment in the code: the context always contains view, the view instance, which JsonResponse cannot serialise:

catalog/views_mixins.py: one mixin, two output formats from the same view
from django.http import JsonResponse
from django.views.generic import TemplateView


class JSONResponseMixin:
    """Renders the template context as JSON instead of HTML."""

    def render_to_json_response(self, context, **response_kwargs):
        return JsonResponse(self.get_data(context), **response_kwargs)

    def get_data(self, context):
        # ContextMixin puts the view itself under "view": not JSON-serializable.
        return {key: value for key, value in context.items() if key != "view"}


class NegotiatedPageView(JSONResponseMixin, TemplateView):
    template_name = "catalog/pages/default.html"
    extra_context = {"title": "Opening hours"}

    def render_to_response(self, context, **response_kwargs):
        if self.request.GET.get("format") == "json":
            return self.render_to_json_response(context, **response_kwargs)
        return super().render_to_response(context, **response_kwargs)
$ curl -s "localhost:8723/hours/?format=json"
{"title": "Opening hours"}
$ curl -s localhost:8723/hours/
<h1>Opening hours</h1>

super().render_to_response(…​) inside NegotiatedPageView reaches TemplateResponseMixin because JSONResponseMixin does not define that method. For anything beyond toy payloads serialise with Django REST framework or Django Ninja instead (see Django REST Framework); this pattern is for small internal endpoints and for understanding the machinery.

Dispatch mixins: order is behaviour

Cross-cutting rules are the best use of dispatch() mixins. Order decides which one acts first: the leftmost mixin’s dispatch() runs first and passes the call along with super(). Here auditing is leftmost, so even refused requests are logged; reversing the first two would log only requests that pass the staff check. CurrentYearMixin shows the cooperative get_context_data() pattern: take what the next class in the MRO produced, add to it, return it.

catalog/views_mixins.py: three mixins and the view that stacks them, plus the same view built wrongly
import datetime
import logging

from django.http import HttpResponseForbidden
from django.views.generic import TemplateView

logger = logging.getLogger(__name__)


class CurrentYearMixin:
    """Cooperates with the rest of the MRO: adds a key, then lets the others add theirs."""

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["year"] = datetime.date.today().year
        return context


class StaffOnlyMixin:
    def dispatch(self, request, *args, **kwargs):
        if not request.user.is_staff:
            return HttpResponseForbidden("staff only")
        return super().dispatch(request, *args, **kwargs)


class AuditMixin:
    def dispatch(self, request, *args, **kwargs):
        logger.info("%s %s", request.method, request.path)
        return super().dispatch(request, *args, **kwargs)


class StaffDashboardView(AuditMixin, StaffOnlyMixin, CurrentYearMixin, TemplateView):
    template_name = "catalog/pages/default.html"
    extra_context = {"title": "Staff dashboard"}


class BrokenDashboardView(TemplateView, StaffOnlyMixin):
    """BUG: the mixin is to the right of the view, so View.dispatch() never reaches it."""

    template_name = "catalog/pages/default.html"
>>> c = Client()                                  # anonymous
>>> c.get("/staff/").status_code                  # AuditMixin logs, StaffOnlyMixin refuses
403
>>> c.get("/broken-staff/").status_code           # TemplateView before the mixin: the check never runs
200

BrokenDashboardView(TemplateView, StaffOnlyMixin) is the most common mixin bug and it fails open: an anonymous visitor reads a staff page and nothing reports an error. Write a test for every access mixin that requests the view as an unauthorised user (the test suite above does so for both views), because the failure is invisible otherwise.

SingleObjectMixin with a plain View

The object-lookup half of DetailView is SingleObjectMixin: model or queryset, get_queryset(), and get_object(), which reads the pk or slug URL captures (pk_url_kwarg, slug_url_kwarg) and answers 404 through Http404 when nothing matches. Combined with a bare View it gives a view of your own design that still gets the lookup for free:

catalog/views_mixins.py
from django.http import JsonResponse
from django.views import View
from django.views.generic.detail import SingleObjectMixin

from .models import Book


class BookDetailView(SingleObjectMixin, View):
    """JSON detail of one book: SingleObjectMixin supplies get_object()."""

    model = Book

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        return JsonResponse(
            {"id": self.object.pk, "title": self.object.title, "visits": self.object.visits}
        )
$ curl -s localhost:8723/books/1/
{"id": 1, "title": "Earthsea", "visits": 0}
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8723/books/999/
404

The full set of single-object and multiple-object mixins, and how DetailView and ListView assemble them, is on Generic Views.

Combining list and single-object mixins

Sometimes one page needs a list that hangs off a single object: the books of one author, paginated. ListView provides pagination; SingleObjectMixin finds the author. The two need different querysets, so the code states which is which: get_object(queryset=Author.objects.all()) for the author, and an overridden get_queryset() for the books. Both mixins write into the context under context_object_name, so the code adds the author under an explicit key.

catalog/views_mixins.py
from django.views.generic import ListView
from django.views.generic.detail import SingleObjectMixin

from .models import Author


class AuthorBooksView(SingleObjectMixin, ListView):
    """Paginated list of one author's books: two querysets, one view."""

    paginate_by = 2
    template_name = "catalog/author_books.html"

    def get(self, request, *args, **kwargs):
        self.object = self.get_object(queryset=Author.objects.all())
        return super().get(request, *args, **kwargs)

    def get_queryset(self):
        return self.object.books.order_by("title")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["author"] = self.object
        return context
<h1>{{ author.name }}</h1>
<ol>{% for book in page_obj %}<li>{{ book.title }}</li>{% endfor %}</ol>
<p>Page {{ page_obj.number }} of {{ paginator.num_pages }}</p>
$ curl -s "localhost:8723/authors/1/books/?page=2"
<h1>Ursula K. Le Guin</h1>
<ol><li>The Dispossessed</li></ol>
<p>Page 2 of 2</p>

This is as far as combining generic mixins is wise. Django’s own advice is to draw each view from one family only (detail, list, editing or date-based) and, when you need two of them, to run two views.

Two views, one URL

A page that shows an object and also accepts a form posted to the same URL is the classic temptation to mix DetailView with FormMixin, which works only with considerable care. A sturdier shape is a thin dispatching view that hands each verb to a purpose-built view, because as_view() returns an ordinary function that can be called inside a handler:

catalog/views_mixins.py: GET renders the page, POST handles the form
from django.urls import reverse
from django.views import View
from django.views.generic import FormView, TemplateView
from django.views.generic.detail import SingleObjectMixin

from .forms import ReviewForm
from .models import Book


class BookHtmlView(SingleObjectMixin, TemplateView):
    model = Book
    template_name = "catalog/book_page.html"

    def get_context_data(self, **kwargs):
        self.object = self.get_object()
        context = super().get_context_data(**kwargs)
        context["form"] = ReviewForm()
        return context


class ReviewFormView(SingleObjectMixin, FormView):
    model = Book
    form_class = ReviewForm
    template_name = "catalog/book_page.html"

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super().post(request, *args, **kwargs)

    def get_success_url(self):
        return reverse("book-page", kwargs={"pk": self.object.pk})


class BookPageView(View):
    """One URL, two views: GET renders the page, POST handles the form."""

    def get(self, request, *args, **kwargs):
        return BookHtmlView.as_view()(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return ReviewFormView.as_view()(request, *args, **kwargs)
catalog/forms.py
from django import forms


class ReviewForm(forms.Form):
    message = forms.CharField(max_length=500)
>>> url = reverse("book-page", kwargs={"pk": 1})
>>> c.get(url).status_code
200
>>> r = c.post(url, {"message": ""}); r.status_code, bool(r.context["form"].errors)
(200, True)
>>> r = c.post(url, {"message": "Loved it"}); r.status_code, r["Location"]
(302, '/books/1/page/')

FormView, form_valid(), form_invalid() and get_success_url() are explained on Generic Views and on Forms; the point here is the composition: each class does one job, and the dispatcher keeps the URLconf to a single entry. The same technique serves content negotiation and A/B experiments.

Where as_view() sits in the request cycle

A request reaches your class only after the middleware chain and the URL resolver. The resolver matches the path and returns the function from as_view(); the handler then runs the process_view() hooks of every middleware with that function and the captured arguments, and only then calls the function. That is why decorators and middleware cannot see your class directly, but can recover it from view_class:

catalog/middleware.py: a middleware that records which class served the request (installed first in MIDDLEWARE)
class ViewClassHeaderMiddleware:
    """Shows where as_view() sits in the request cycle: process_view() receives
    the function that as_view() returned and can read its view_class."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        label = getattr(request, "view_label", None)
        if label:
            response["X-View"] = label
        return response

    def process_view(self, request, view_func, view_args, view_kwargs):
        view_class = getattr(view_func, "view_class", None)
        if view_class is not None:
            request.view_label = f"{view_class.__module__}.{view_class.__qualname__}"
        else:
            request.view_label = f"{view_func.__module__}.{view_func.__name__}"
        return None  # None means: carry on to the view

The X-View header in the curl output earlier (catalog.views_basic.GreetingView) comes from this middleware. Functions have no view_class, so the same middleware labels a function view with its dotted name. The same attribute is on resolve("/greet/").func, which is how tools such as the debug toolbar and test helpers identify the view class. After the view returns, a TemplateResponse is rendered, process_template_response() and the response-phase middleware run in reverse order, and the response goes out; an exception raised anywhere in your methods is passed to process_exception() and then turned into a 404, 403, 400 or 500 response by the exception handlers described on Request/Response Cycle and Middleware.

The middleware above is a plain synchronous class. Under ASGI, with async views, Django adapts a sync-only middleware by running it in a thread, which has a measurable cost; middleware that declares async_capable avoids it. For a project that is mostly synchronous none of this matters.

Async class-based views

Django has run async views since 3.1; since 4.1 a View subclass may define async def handlers. There is no special base class: you write async def get(…​) instead of def get(…​). as_view() checks the handlers (everything in http_method_names except options) and, when they are coroutine functions, marks the function it returns as a coroutine function, so Django’s handler awaits it on ASGI instead of running it in a thread. View.options() and http_method_not_allowed() detect the same condition and wrap their responses in a coroutine, so OPTIONS and 405 work in async views without code from you (the test suite checks both).

catalog/views_async.py: native async handlers, concurrent queries, async iteration, and a sync function bridged explicitly
import asyncio
import time

from asgiref.sync import sync_to_async
from django.http import JsonResponse
from django.views import View

from .models import Author, Book


class AsyncStatsView(View):
    async def get(self, request, *args, **kwargs):
        books, authors = await asyncio.gather(Book.objects.acount(), Author.objects.acount())
        return JsonResponse({"books": books, "authors": authors})


class AsyncTitlesView(View):
    async def get(self, request, *args, **kwargs):
        titles = [book.title async for book in Book.objects.order_by("title")]
        return JsonResponse({"titles": titles})

    async def post(self, request, *args, **kwargs):
        # A plain sync function (here a stand-in for a blocking library) is bridged explicitly.
        created = await sync_to_async(self._create_sync)(request.POST.get("title", "Untitled"))
        return JsonResponse({"created": created}, status=201)

    def _create_sync(self, title):
        author, _ = Author.objects.get_or_create(name="Anonymous")
        book = Book.objects.create(title=title, isbn=f"x{time.time_ns()}"[:13], author=author, price=0)
        return book.title

Inside the handlers, use the a-prefixed ORM methods (acount(), aget(), acreate()), async for over a QuerySet, and await sync_to_async(function)() for anything that is still synchronous-only. Calling a synchronous ORM method directly from an async handler raises SynchronousOnlyOperation; see Async Support and Making Queries for the async ORM.

Run on Uvicorn, a native ASGI server (the same code under runserver is WSGI):

$ STOCK_WEBHOOK_SECRET=s3cret python -m uvicorn config.asgi:application --port 8724
$ curl -si localhost:8724/async/stats/ | grep -i "^HTTP\|^server\|^content-type"
HTTP/1.1 200 OK
server: uvicorn
Content-Type: application/json
$ curl -s localhost:8724/async/stats/
{"books": 3, "authors": 1}
$ curl -si -X OPTIONS localhost:8724/async/stats/ | grep -i "^HTTP\|^allow"
HTTP/1.1 200 OK
Allow: GET, HEAD, OPTIONS

What async buys you, and where it does not apply

  • Under ASGI an async handler does not hold a thread while it waits for I/O, so one process can serve hundreds of slow connections and can run several awaits concurrently (asyncio.gather above).

  • Under WSGI (runserver, Gunicorn sync workers) Django runs each async view in a one-off event loop. The code still works, and concurrent awaits inside one request still overlap, but you get none of the connection-scaling benefit and pay a small adaptation cost per request. The same AsyncStatsView answered correctly from runserver during testing.

  • A request is still the unit of work. Async is for waiting on networks and databases; CPU-bound work blocks the event loop under ASGI exactly as it blocks a thread under WSGI.

  • Transactions and persistent connections are the limits. Transactions do not work across await in async mode, so wrap transactional code in a synchronous function and call it through sync_to_async(); see Transactions, Raw SQL & Multiple Databases. Deployment and server choices are on Deployment.

The all-or-nothing rule

as_view() refuses a class whose handlers are a mixture of sync and async, because one function cannot be both. The check runs when the URLconf is imported, so the project fails at startup rather than on a request:

catalog/views_async.py
from django.http import HttpResponse
from django.views import View
from django.views.generic import CreateView

from .models import Book


class MixedView(View):
    """All handlers must be sync or all async; Django refuses a mixture."""

    def get(self, request, *args, **kwargs):
        return HttpResponse("sync")

    async def post(self, request, *args, **kwargs):
        return HttpResponse("async")


class AsyncCreateAttempt(CreateView):
    """CreateView ships sync get() and post(); an async get() makes the handlers a mixture."""

    model = Book
    fields = ["title"]

    async def get(self, request, *args, **kwargs):
        return HttpResponse("async get")
>>> MixedView.as_view()
django.core.exceptions.ImproperlyConfigured: MixedView HTTP handlers must either be all sync or all async.
>>> AsyncCreateAttempt.as_view()
django.core.exceptions.ImproperlyConfigured: AsyncCreateAttempt HTTP handlers must either be all sync or all async.

The second case is the practical consequence for the built-in generic views: their handlers (get(), post(), put()) are ordinary def methods, so declaring one handler async produces a mixture, and there is no async variant of ListView or CreateView. Make a view async by building it from View (and the context and template mixins if you want them) and writing every handler yourself. ListView with only an async get() does pass the check, but then nothing of `ListView’s own logic is awaitable, so it saves nothing.

Async dispatch(), decorators and mixins

An override of dispatch() that post-processes the response must be async def and await the call to super().dispatch(…​), because with async handlers that call returns a coroutine, not a response:

catalog/views_async.py: an async dispatch mixin, and the same pattern wrapped in method_decorator
import time

from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache


class AsyncTimedMixin:
    """An async-aware dispatch(): it must be async def and await the handler."""

    async def dispatch(self, request, *args, **kwargs):
        started = time.perf_counter()
        response = await super().dispatch(request, *args, **kwargs)
        response["X-Elapsed-Ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
        return response


class TimedAsyncStatsView(AsyncTimedMixin, AsyncStatsView):
    pass


class AwaitedDispatchView(View):
    @method_decorator(never_cache)
    async def dispatch(self, *args, **kwargs):
        return await super().dispatch(*args, **kwargs)

    async def get(self, request, *args, **kwargs):
        return HttpResponse("ok")

The decorator example in the Asynchronous support topic guide, checked on 2026-10-03 against the 6.1 page, writes return super().dispatch(*args, **kwargs) inside the async def dispatch(), without await. Run on Django 6.1.1 that variant answers 500 with ValueError: The view …​ didn’t return an HttpResponse object. It returned an unawaited coroutine instead. You may need to add an 'await' into your view.; the awaited form above answers 200 with the Cache-Control header from never_cache. The failing variant, kept in the scratch project as UnawaitedDispatchView:

catalog/views_async.py: the 500
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.cache import never_cache


class UnawaitedDispatchView(View):
    """BUG: an async dispatch() that returns the handler's coroutine instead of awaiting it."""

    @method_decorator(never_cache)
    async def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

    async def get(self, request, *args, **kwargs):
        return HttpResponse("ok")
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8724/async/unawaited/
500

A sync mixin in front of an async view is the other classic failure. LoginRequiredMixin.dispatch() is a plain def: for a signed-in user it returns the handler’s coroutine, which works, but for an anonymous user it returns a redirect response, and Django then tries to await it:

catalog/views_async.py: the failure, and an async-aware replacement using request.auser()
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponse
from django.views import View


class SyncMixinOnAsyncView(LoginRequiredMixin, View):
    """BUG: LoginRequiredMixin.dispatch() is a plain def; its redirect cannot be awaited."""

    async def get(self, request, *args, **kwargs):
        return HttpResponse("members only")


class AsyncLoginRequiredMixin:
    async def dispatch(self, request, *args, **kwargs):
        user = await request.auser()
        if not user.is_authenticated:
            return redirect_to_login(request.get_full_path())
        return await super().dispatch(request, *args, **kwargs)


class MembersAsyncView(AsyncLoginRequiredMixin, View):
    async def get(self, request, *args, **kwargs):
        user = await request.auser()
        return HttpResponse(f"members only, {user.get_username()}")
>>> r = await AsyncClient(raise_request_exception=False).get("/async/sync-mixin/")
>>> r.status_code        # TypeError: object HttpResponseRedirect can't be used in 'await' expression
500
>>> r = await client.get("/async/members/"); r.status_code, r["Location"]
(302, '/accounts/login/?next=/async/members/')
>>> await client.aforce_login(user); (await client.get("/async/members/")).content
b'members only, amy'

Rules of thumb that follow: put async def dispatch() mixins in front of async views and sync ones in front of sync views; for access control on async views prefer the decorators, which support async functions (login_required(), permission_required() and user_passes_test() since 5.1, the cache, CSRF and conditional-processing decorators since 5.0 or later) applied through method_decorator (async methods supported since 5.2), or request.auser() (Django 5.0 added the async authentication API) as above.

Testing class-based views

Test a CBV at three levels, cheapest first. Calling as_view()(request) with a RequestFactory request runs setup(), dispatch() and the handler with no URLconf, middleware, session or database unless the view needs them; use it for unit tests of one view’s branches and of get_context_data(). The test client goes through the whole stack and is right for routing, middleware and templates. AsyncRequestFactory and AsyncClient are the async equivalents (the test method must itself be async def).

catalog/tests_cbv.py: unit, integration and async tests
import inspect

from django.test import AsyncClient, AsyncRequestFactory, RequestFactory, SimpleTestCase, TestCase
from django.urls import resolve

from .models import Author, Book
from .views_async import AsyncStatsView
from .views_basic import GreetingView, TimedView
from .views_template import HomeView


class UnitTests(SimpleTestCase):
    """No database, no URLconf, no middleware: call the view the way the resolver would."""

    def test_as_view_called_directly(self):
        request = RequestFactory().get("/greet/Bo/")
        response = GreetingView.as_view(greeting="Hi")(request, name="Bo")
        self.assertEqual(response.content, b"Hi, Bo!")

    def test_method_not_allowed(self):
        request = RequestFactory().delete("/greet/")
        response = GreetingView.as_view()(request)
        self.assertEqual(response.status_code, 405)
        self.assertEqual(response["Allow"], "GET, POST, HEAD, OPTIONS")

    def test_setup_without_a_request_cycle(self):
        view = TimedView()
        request = RequestFactory().get("/")
        view.setup(request, 1, key="value")
        self.assertEqual((view.request, view.args, view.kwargs), (request, (1,), {"key": "value"}))
        self.assertTrue(view.request_id)


class IntegrationTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        author = Author.objects.create(name="Octavia E. Butler")
        Book.objects.create(title="Kindred", isbn="9780807083697", author=author, price="12.50")

    def test_template_response_is_lazy_until_rendered(self):
        request = RequestFactory().get("/")
        response = HomeView.as_view()(request)  # a TemplateResponse, not yet rendered
        self.assertFalse(response.is_rendered)
        self.assertEqual(response.context_data["site_name"], "Bookshelf")
        response.render()
        self.assertIn(b"Kindred", response.content)

    def test_resolver_exposes_view_class(self):
        match = resolve("/")
        self.assertIs(match.func.view_class, HomeView)
        self.assertEqual(match.func.view_initkwargs, {})

    def test_client_goes_through_middleware_and_urls(self):
        response = self.client.get("/")
        self.assertTemplateUsed(response, "catalog/home.html")
        self.assertEqual(response["X-View"], "catalog.views_template.HomeView")


class AsyncTests(TestCase):
    async def test_async_view_with_async_factory(self):
        request = AsyncRequestFactory().get("/async/stats/")
        response = await AsyncStatsView.as_view()(request)
        self.assertEqual(response.status_code, 200)

    async def test_async_client(self):
        response = await AsyncClient().get("/async/stats/")
        self.assertEqual(response.json(), {"books": 0, "authors": 0})


class AsyncMarkingTests(SimpleTestCase):
    def test_as_view_marks_async_classes(self):
        self.assertTrue(inspect.iscoroutinefunction(AsyncStatsView.as_view()))
        self.assertFalse(inspect.iscoroutinefunction(GreetingView.as_view()))
$ python manage.py test
Found 43 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........................................
----------------------------------------------------------------------
Ran 43 tests in 0.5s

OK

Three facts from these tests are useful in practice: a TemplateResponse returned directly from as_view()(request) is unrendered (is_rendered is False and context_data is readable) until render() is called; resolve(path).func.view_class identifies the class mounted at a URL; and view.setup(request, …​) can be called on an instance by hand to test per-request attributes. See Testing for the test client and pytest-django.

Function-based or class-based?

Neither replaces the other; the Django documentation says so in its first paragraph on class-based views. They are two ways to write the same contract, and a project normally contains both. What differs is how each style handles reuse, branching and readability:

Concern Function-based view Class-based view

One screen, one purpose

Excellent: the whole behaviour is visible top to bottom.

More ceremony; the behaviour is split into methods and inherited hooks.

Branching by HTTP verb

An if request.method == …​ ladder, or decorators such as require_POST.

Separate get() and post() methods; 405 for the rest, automatically.

Reuse across views

Helper functions and decorators.

Mixins and inheritance, as_view() keyword configuration.

Standard patterns (list, detail, create, update, delete)

Written by hand each time.

One short subclass of a generic view.

Cross-cutting rules (login, audit, tenant)

Decorators, applied per view or in the URLconf.

Mixins or method_decorator on dispatch().

Reading code you did not write

Everything is in one function.

You need the MRO and Django’s source to know which method runs; deep hierarchies hurt.

Async

async def, one function.

async def handlers, all-or-nothing, no async generic views.

Testing

Call the function with a request.

as_view()(request), or the test client; both easy.

A working rule, in the spirit of the advice in Two Scoops of Django and equally valid for Django 6.1: use the simplest view that is clear. A generic CBV for the standard CRUD pages, a TemplateView for static pages, a function view for one-off logic, webhooks and anything with branching that would need three overridden hooks in a class, and a plain View when you want verb separation without generic machinery. Keep views thin whichever style you choose: validation in forms, queries in managers and QuerySets (see Managers & Custom QuerySets), business rules in service functions, so that the view only translates between HTTP and those layers. Mixins are for behaviour that really is shared; two mixins with overlapping hooks are usually a sign that a function or a service object would be simpler. For JSON APIs prefer Django REST framework’s own class-based views or Django Ninja functions over bending the template views of this page.

Reference: attributes and methods at a glance

Class Member Notes

View

http_method_names

Verbs that may reach a handler; default get, post, put, patch, delete, head, options, trace.

View

as_view(**initkwargs)

Class method; validates keywords; returns the view function with view_class and view_initkwargs.

View

setup(request, *args, **kwargs)

Sets request, args, kwargs, and head = get. Call super() when overriding.

View

dispatch(request, *args, **kwargs)

Routes to the handler named after the lowercase method, or to http_method_not_allowed().

View

http_method_not_allowed(…​)

HttpResponseNotAllowed with Allow; logs a warning.

View

options(…​)

Empty 200 with an Allow header.

ContextMixin

extra_context

Constant context; also accepted by as_view().

ContextMixin

get_context_data(**kwargs)

Returns kwargs plus view and extra_context. Override, call super(), return the dict.

TemplateResponseMixin

template_name, template_engine, response_class, content_type

Rendering configuration; all overridable, also through as_view().

TemplateResponseMixin

get_template_names(), render_to_response(context, **kwargs)

Template selection (a list) and response construction (TemplateResponse, lazy).

TemplateView

get()

render_to_response(get_context_data(**kwargs)).

RedirectView

url, pattern_name, permanent, query_string, preserve_request (6.1)

See the table in the RedirectView section.

RedirectView

get_redirect_url(*args, **kwargs)

Returns the target or None (410). Called by every verb.

Pitfalls checklist

  • Mutable class attributes are shared by all requests; assign them in setup().

  • as_view() arguments are evaluated once, at import time, including extra_context; per-request values go in get_context_data().

  • Forgetting super() in setup(), dispatch() or get_context_data() silently disables the rest of the MRO (or fails loudly for setup()).

  • A mixin to the right of the view class never runs, and access mixins then fail open. Test as an anonymous user.

  • A raw URL in RedirectView.get_redirect_url() from user input is an open redirect; accept keys and reverse() names.

  • csrf_exempt removes a protection; use it only on machine-to-machine endpoints that authenticate some other way.

  • CSRF answers before the 405: unsafe verbs without a token are 403 in a browser or curl, 405 in the test client.

  • context contains view; strip it before serialising the context.

  • Sync and async handlers cannot be mixed, and sync dispatch mixins (including LoginRequiredMixin) break async views for the cases where they return a response.

  • An async dispatch() must await the call to super().dispatch().

  • Heavy hierarchies are hard to read: if you need the MRO diagram to explain a view, simplify it.

Where the books are outdated

The four reference books for this section were written for Django 1.11–2.1. For class-based views their fundamentals (View, as_view(), dispatch(), mixins, MRO, method_decorator) are unchanged, but several surrounding details have moved on. Whenever a listing in an older book or tutorial disagrees with this page, trust this page and the 6.1 documentation.

Older code or advice Today Since

from django.conf.urls import url; url(r"^greet/$", GreetingView.as_view())

path("greet/", GreetingView.as_view()), or re_path() for regular expressions; url() is gone.

removed in 4.0

render_to_response("t.html", context) in a handler

render(request, "t.html", context), or a TemplateResponse; TemplateView and every template mixin already use the latter.

removed in 3.0

if request.is_ajax(): to switch behaviour in a view

Inspect the Accept or X-Requested-With header yourself, or serve a separate endpoint.

removed in 4.0

Class-based views are synchronous only

async def handlers are supported; the all-or-nothing rule applies.

async views 3.1, async CBV handlers 4.1

Custom deletion logic in a delete() method of a DeleteView

Put it in form_valid(): DeleteView uses FormMixin, so it can validate a confirmation form and works with SuccessMessageMixin.

4.0

LogoutView logs out on a GET link

Logging out requires a POST form (GET was deprecated in 4.1).

5.0

Protect a whole site with a custom middleware or a mixin on every view

LoginRequiredMiddleware and login_not_required().

5.1

Decorators cannot be used on async views

The documented decorators wrap sync and async views.

5.0 and later; method_decorator 5.2

A hand-written 307/308 response to keep a POST across a redirect

RedirectView(preserve_request=True).

6.1

What changed recently

Version Change for class-based views

3.1

Async views (async def function views).

4.0

DeleteView is built on FormMixin.

4.1

View subclasses may define async HTTP method handlers.

5.0

Under ASGI http.disconnect events are handled; cache, CSRF and similar decorators wrap async view functions; LogoutView is POST-only.

5.1

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

5.2

method_decorator() supports wrapping asynchronous view methods.

6.1

RedirectView.preserve_request (307/308 instead of 302/301).

There is no new base class or behavioural change in View, TemplateView or RedirectView in 6.0 beyond the above; the 6.0 release notes contain no entry for them. Django 5.2 LTS supports everything on this page except preserve_request.

The whole URLconf

catalog/urls.py: every route used on this page
from django.urls import path
from django.views.generic import RedirectView, TemplateView

from . import (
    views_async,
    views_basic,
    views_decorated,
    views_mixins,
    views_redirect,
    views_template,
)

urlpatterns = [
    # views_basic
    path("greet/", views_basic.GreetingView.as_view(), name="greet"),
    path("greet/<str:name>/", views_basic.GreetingView.as_view(greeting="Welcome"), name="greet-named"),
    path("book-count/", views_basic.BookCountView.as_view(), name="book-count"),
    path("shared/", views_basic.SharedStateView.as_view(), name="shared"),
    path("per-request/", views_basic.PerRequestStateView.as_view(), name="per-request"),
    path("forgetful/", views_basic.ForgetfulView.as_view(), name="forgetful"),
    # views_template
    path("", views_template.HomeView.as_view(), name="home"),
    path(
        "about/",
        TemplateView.as_view(template_name="catalog/about.html", extra_context={"site_name": "Bookshelf"}),
        name="about",
    ),
    path("pages/<slug:slug>/", views_template.PageView.as_view(), name="page"),
    path("robots.txt", views_template.RobotsView.as_view(), name="robots"),
    # views_redirect
    path("books/", views_basic.BookListView.as_view(), name="book-list"),
    path("books/<int:pk>/", views_mixins.BookDetailView.as_view(), name="book-detail"),
    path("b/<str:isbn>/", views_redirect.BookShortLinkView.as_view(), name="book-short"),
    path("old-catalogue/", RedirectView.as_view(pattern_name="book-list", permanent=True, query_string=True)),
    path("legacy/<int:pk>/", RedirectView.as_view(url="/books/%(pk)s/", permanent=True)),
    path("docs/", RedirectView.as_view(url="https://docs.djangoproject.com/en/6.1/")),
    path("retired/", RedirectView.as_view(url=None), name="retired"),
    path(
        "old-order/",
        RedirectView.as_view(pattern_name="greet", permanent=True, preserve_request=True),
        name="old-order",
    ),
    path("go/", views_redirect.SectionRedirectView.as_view(), name="go"),
    # views_decorated
    path("shelf/", views_decorated.PrivateShelfView.as_view(), name="shelf"),
    path("account/", views_decorated.AccountView.as_view(), name="account"),
    path("hooks/stock/", views_decorated.StockWebhookView.as_view(), name="stock-hook"),
    path("form-post/", views_decorated.FormPostView.as_view(), name="form-post"),
    # views_mixins
    path("hours/", views_mixins.NegotiatedPageView.as_view(), name="hours"),
    path("staff/", views_mixins.StaffDashboardView.as_view(), name="staff"),
    path("broken-staff/", views_mixins.BrokenDashboardView.as_view(), name="broken-staff"),
    path("authors/<int:pk>/books/", views_mixins.AuthorBooksView.as_view(), name="author-books"),
    path("books/<int:pk>/page/", views_mixins.BookPageView.as_view(), name="book-page"),
    # views_async
    path("async/stats/", views_async.AsyncStatsView.as_view(), name="async-stats"),
    path("async/titles/", views_async.AsyncTitlesView.as_view(), name="async-titles"),
    path("async/timed/", views_async.TimedAsyncStatsView.as_view(), name="async-timed"),
    path("async/unawaited/", views_async.UnawaitedDispatchView.as_view(), name="async-unawaited"),
    path("async/sync-mixin/", views_async.SyncMixinOnAsyncView.as_view(), name="async-sync-mixin"),
    path("async/members/", views_async.MembersAsyncView.as_view(), name="async-members"),
    path("async/awaited/", views_async.AwaitedDispatchView.as_view(), name="async-awaited"),
]
$ python manage.py check
System check identified no issues (0 silenced).