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.
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 toas_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 receiverequestas an argument and return a response exactly like a function view; they can also readself.request,self.argsandself.kwargs. Shortcuts such asrender()andredirect()and response classes such asTemplateResponsework 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_classandview_initkwargs, for code that needs the class back.
Source: Introduction to class-based views and Base views
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:
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.
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)
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:
$ 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:
-
HEADisGETwithout a body.setup()assignsself.head = self.getwhen the class definesget()but nohead(). Definehead()yourself only when you can answer cheaper than running the wholeget(). -
OPTIONSis built in.View.options()returns an empty 200 response whoseAllowheader lists every handler the instance has (GET, POST, HEAD, OPTIONSabove;HEADappears becausesetup()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()returnsHttpResponseNotAllowedwith the sameAllowlist, an empty body, and writes a warning to thedjango.requestlogger (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 onself.argsandself.kwargs. Declare*args, **kwargson 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():
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')
Source: View.http_method_names
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 aTypeError; -
a keyword that is not already an attribute of the class (
as_view(colour="red")) is aTypeError, 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 incls.dispatch.dict, which is how decorators that set an attribute on the function (csrf_exemptsetscsrf_exempt = True) keep working when you decoratedispatch(); -
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).
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:
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")
$ 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:
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.
Source: View.setup() and View.dispatch()
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.
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()"]
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
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 |
|---|---|
|
The template to render. Required unless |
|
Returns a list of candidate names; the first one that exists is used. Override it to pick a template from request or URL data. |
|
Name of a configured template engine (the |
|
The response type, |
|
The |
|
Builds |
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"
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.
Source: TemplateView and Simple mixins
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 |
|---|---|---|
|
|
The target as a string. Dictionary-style |
|
|
A URL name to |
|
|
|
|
|
|
|
|
New in Django 6.1. |
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.
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.
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):
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:
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_decoratorpassesargsand*kwargsthrough, so the decorated method must accept the argumentsdispatch()receives; a method with a narrower signature raisesTypeError. -
Decorate
dispatch(), notget(), 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_decoratorlines applies the bottom one first, as with ordinary stacked decorators. -
method_decorator()wraps asynchronous methods too since Django 5.2, but decoratingdispatch()of a view with async handlers requires anasync defdispatch()(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.
(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.
-
Mixins go to the left of the view class.
class V(MyMixin, TemplateView). The base view’sdispatch()does not callsuper().dispatch(); it routes to a handler and stops. A mixin placed after it in the bases is behind that wall and itsdispatch()is never called (the broken example below). -
Every override calls
super(). Adispatch()orget_context_data()that forgetssuper()silently disables everything to its right in the MRO. -
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 withinitarguments is almost always a design smell, becauseas_view()configures views by attributes. -
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 (sayListViewwithProcessFormView) mixes twoget()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:
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.
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:
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.
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:
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)
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:
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.
Source: Middleware: process_view() and resolve()
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).
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.gatherabove). -
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 sameAsyncStatsViewanswered correctly fromrunserverduring 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
awaitin async mode, so wrap transactional code in a synchronous function and call it throughsync_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:
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:
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:
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:
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).
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.
Source: RequestFactory and Testing asynchronous code
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 |
Separate |
Reuse across views |
Helper functions and decorators. |
Mixins and inheritance, |
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 |
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 |
|
|
Testing |
Call the function with a request. |
|
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.
Source: Introduction to class-based views and Writing views
Reference: attributes and methods at a glance
| Class | Member | Notes |
|---|---|---|
|
|
Verbs that may reach a handler; default |
|
|
Class method; validates keywords; returns the |
|
|
Sets |
|
|
Routes to the handler named after the lowercase method, or to |
|
|
|
|
|
Empty 200 with an |
|
|
Constant context; also accepted by |
|
|
Returns |
|
|
Rendering configuration; all overridable, also through |
|
|
Template selection (a list) and response construction ( |
|
|
|
|
|
See the table in the RedirectView section. |
|
|
Returns the target or |
Pitfalls checklist
-
Mutable class attributes are shared by all requests; assign them in
setup(). -
as_view()arguments are evaluated once, at import time, includingextra_context; per-request values go inget_context_data(). -
Forgetting
super()insetup(),dispatch()orget_context_data()silently disables the rest of the MRO (or fails loudly forsetup()). -
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 andreverse()names. -
csrf_exemptremoves 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. -
contextcontainsview; 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()mustawaitthe call tosuper().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 |
|---|---|---|
|
|
removed in 4.0 |
|
|
removed in 3.0 |
|
Inspect the |
removed in 4.0 |
Class-based views are synchronous only |
|
async views 3.1, async CBV handlers 4.1 |
Custom deletion logic in a |
Put it in |
4.0 |
|
Logging out requires a |
5.0 |
Protect a whole site with a custom middleware or a mixin on every view |
|
5.1 |
Decorators cannot be used on async views |
The documented decorators wrap sync and async views. |
5.0 and later; |
A hand-written 307/308 response to keep a |
|
6.1 |
What changed recently
| Version | Change for class-based views |
|---|---|
3.1 |
Async views ( |
4.0 |
|
4.1 |
|
5.0 |
Under ASGI |
5.1 |
|
5.2 |
|
6.1 |
|
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
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).
References
-
Django 2 by Example (Packt; latest edition: Django 5 By Example)
-
Two Scoops of Django (Feldroy)