Generic 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. Nothing on this page needs PostgreSQL or Redis: the
listings were run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite. manage.py check is clean, the 38 tests shown
in Running the examples pass (manage.py test catalog), a curl session against runserver returned the responses shown, and the shell
scripts printed the output quoted below.
Generic views are the ready-made class-based views in django.views.generic: they implement the handful of patterns that account for
most pages of a database-backed site — show a list of objects, show one object, show a form, create, change or delete an object, and
browse objects by date. You configure them with class attributes and customise them by overriding small methods, and they are built
from small mixins that you can recombine. The base machinery (View, as_view(), dispatch(), setup(), TemplateView, how the
method resolution order works) belongs to Class-Based Views, the page before this one; this page assumes it and concentrates on what the
generic views add. Models and fields are covered on Models & Fields, filtering and ordering on
Making Queries, and avoiding the N+1 problem in list templates on
Query Optimization & Fetch Modes. Forms, templates, authentication and testing
have their own pages later in the section; here they appear only as far as the views need them.
The Bookshelf catalog app used on this page
Every example belongs to one small app, catalog, of the running Bookshelf scenario: authors, books and reader reviews. The app is
registered in INSTALLED_APPS, its URLs are included under catalog/ in the project’s urls.py
(path("catalog/", include("catalog.urls"))), and the project keeps Django’s defaults for everything else (SQLite, the messages and
session middleware, LOGIN_URL = "/accounts/login/"). The models are deliberately small, but three details matter later:
-
Book.Meta.orderingis set, because paginating an unordered QuerySet gives unstable pages (see Pagination). -
Book.publishedis aDateField, not aDateTimeField, which is what the date-based views want (see Date-based views). -
Reviewbelongs to a user (reviewer) and has aUniqueConstraint, which is what makes it the right model for queryset scoping torequest.user(see Scoping querysets to request.user).
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
class Author(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("catalog:author-books", kwargs={"slug": self.slug})
class Book(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
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)
published = models.DateField()
is_published = models.BooleanField(default=True)
views = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["-published", "title"]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", kwargs={"slug": self.slug})
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
reviewer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
body = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created"]
constraints = [
models.UniqueConstraint(
fields=["book", "reviewer"], name="one_review_per_reader_and_book"
)
]
def __str__(self):
return f"{self.book} ({self.rating}/5)"
def get_absolute_url(self):
return reverse("catalog:book-detail", kwargs={"slug": self.book.slug})
Each model has a get_absolute_url(). The editing views use it to find where to redirect after a successful save, so defining it
once on the model saves a success_url per view.
The forms are one plain form (SuggestionForm, for FormView), one ModelForm that receives the book and the reviewer so it can
reject a second review of the same book as an ordinary form error (ReviewForm), and a one-checkbox form that makes DeleteView ask
for confirmation (ConfirmDeleteForm):
from django import forms
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from .models import Review
class SuggestionForm(forms.Form):
title = forms.CharField(max_length=200)
author_name = forms.CharField(max_length=200)
reason = forms.CharField(widget=forms.Textarea, required=False)
def send_email(self, suggested_by):
send_mail(
subject=f"Book suggestion: {self.cleaned_data['title']}",
message=(
f"{suggested_by} suggests {self.cleaned_data['title']} "
f"by {self.cleaned_data['author_name']}.\n\n"
f"{self.cleaned_data['reason']}"
),
from_email=None, # DEFAULT_FROM_EMAIL
recipient_list=["curators@example.com"],
)
class ReviewForm(forms.ModelForm):
class Meta:
model = Review
fields = ["rating", "body"]
widgets = {"body": forms.Textarea(attrs={"rows": 4})}
def __init__(self, *args, book, reviewer, **kwargs):
super().__init__(*args, **kwargs)
self.book = book
self.reviewer = reviewer
def clean(self):
cleaned_data = super().clean()
duplicates = Review.objects.filter(
book=self.book, reviewer=self.reviewer
).exclude(pk=self.instance.pk)
if duplicates.exists():
raise ValidationError("You have already reviewed this book.")
return cleaned_data
class ConfirmDeleteForm(forms.Form):
confirm = forms.BooleanField(label="Yes, delete this review")
The URLconf (see URLs & Routing) wires every view of this page. Two things are worth noticing: the literal route books/add/ is listed before
books/<slug:slug>/, because the first matching pattern wins and add is a perfectly good slug; and the date-based routes capture
year, month, day and week under exactly those names, which is what the date mixins look for.
from django.urls import path
from django.views.generic import TemplateView
from . import views
app_name = "catalog"
urlpatterns = [
# display views
path("", views.BookListView.as_view(), name="book-list"),
path("authors/<slug:slug>/", views.AuthorBookListView.as_view(), name="author-books"),
path("books.json", views.BookJsonListView.as_view(), name="book-json"),
path("flexible/", views.FlexibleBookListView.as_view(), name="flexible-list"),
# editing views
path("suggest/", views.SuggestBookView.as_view(), name="suggest"),
path(
"suggest/thanks/",
TemplateView.as_view(template_name="catalog/suggest_thanks.html"),
name="suggest-thanks",
),
path("books/add/", views.BookCreateView.as_view(), name="book-add"),
path("books/<slug:slug>/", views.BookDetailView.as_view(), name="book-detail"),
path("books/<slug:slug>/review/", views.ReviewCreateView.as_view(), name="review-add"),
path("my-reviews/", views.MyReviewListView.as_view(), name="my-reviews"),
path("my-reviews/<int:pk>/edit/", views.ReviewUpdateView.as_view(), name="review-edit"),
path(
"my-reviews/<int:pk>/delete/", views.ReviewDeleteView.as_view(), name="review-delete"
),
# date-based views
path("archive/", views.BookArchiveIndexView.as_view(), name="archive"),
path("archive/today/", views.BookTodayArchiveView.as_view(), name="archive-today"),
path("archive/<int:year>/", views.BookYearArchiveView.as_view(), name="archive-year"),
path(
"archive/<int:year>/<int:month>/",
views.BookMonthArchiveView.as_view(),
name="archive-month",
),
path(
"archive/<int:year>/week/<int:week>/",
views.BookWeekArchiveView.as_view(),
name="archive-week",
),
path(
"archive/<int:year>/<int:month>/<int:day>/",
views.BookDayArchiveView.as_view(),
name="archive-day",
),
path(
"archive/<int:year>/<int:month>/<int:day>/<slug:slug>/",
views.BookDateDetailView.as_view(),
name="archive-detail",
),
# async variants of the display views
path("async/books/", views.AsyncBookListView.as_view(), name="async-book-list"),
path("async/protected/", views.ProtectedAsyncBookListView.as_view(), name="async-protected"),
path("async/books/<slug:slug>/", views.AsyncBookDetailView.as_view(), name="async-book-detail"),
]
Source: Built-in class-based generic views, Form handling with class-based views and Generic date views
The templates are ordinary Django templates; they are listed where each view needs them. All of them extend one tiny base template:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{% block title %}Bookshelf{% endblock %}</title></head>
<body>
<nav><a href="{% url 'catalog:book-list' %}">Books</a> | <a href="{% url 'catalog:archive' %}">Archive</a></nav>
{% for message in messages %}<p class="message">{{ message }}</p>{% endfor %}
{% block content %}{% endblock %}
</body>
</html>
The views read their data with self.request, self.args and self.kwargs. A seeded database is assumed for the display and date
examples: seven published books by two authors (Ada Writer and Bo Novelist, between 2024-01-10 and 2025-12-31) and one review.
What the generic views are
The generic views are not a separate framework. They are View subclasses assembled from mixins, and the mixins are the real API.
The table groups the thirteen concrete classes the documentation calls "generic" by the job they do, with the three things you must
know about each: the default template name suffix (the template is <app_label>/<model_name><suffix>.html), the context variables
the template receives, and the HTTP methods the view answers.
| View | Job | Template suffix | Context variables | Methods |
|---|---|---|---|---|
|
A page of objects (optionally paginated) |
|
|
GET, HEAD, OPTIONS |
|
One object, looked up by |
|
|
GET, HEAD, OPTIONS |
|
A plain (non-model) form |
none — set |
|
GET, POST, PUT, HEAD, OPTIONS |
|
A |
|
|
GET, POST, PUT, HEAD, OPTIONS |
|
A |
|
|
GET, POST, PUT, HEAD, OPTIONS |
|
A confirmation page, then deletion |
|
|
GET, POST, DELETE, HEAD, OPTIONS |
|
The latest objects and the years that have objects |
|
|
GET, HEAD, OPTIONS |
|
Months of one year that have objects |
|
|
GET, HEAD, OPTIONS |
|
Objects of one month |
|
|
GET, HEAD, OPTIONS |
|
Objects of one week |
|
|
GET, HEAD, OPTIONS |
|
Objects of one day (today for the second) |
|
|
GET, HEAD, OPTIONS |
|
One object, looked up by date and |
|
|
GET, HEAD, OPTIONS |
Source: Generic display views, Generic editing views and Generic date views
Every one of these classes also exists as a Base… twin (BaseListView, BaseDetailView, BaseCreateView, BaseDateDetailView
and so on) that has the same behaviour but no template rendering. They are the building block for views that render something other
than a template — JSON, CSV, a PDF — by combining the Base view with your own response code. The same mixins also appear in
every view’s context as the variable view, a reference to the view instance, so a template can read view.kwargs or any view
attribute; mark methods that change data with alters_data = True so the template engine refuses to call them.
Generic views are sync code. Section Sync and async behaviour explains what that means under ASGI and how to write an async variant.
Where a generic view sits in the request/response cycle
A generic view is an ordinary Django view: the URL resolver finds the URL pattern, calls the callable that as_view() returned, and
whatever HttpResponse comes back travels out through the middleware in reverse order. Everything specific to generic views happens
inside that one call:
(already through the middleware chain)"] --> res["URL resolver matches a pattern
captures year, slug, pk ..."] res --> asview["view() returned by as_view()
creates a NEW view instance per request"] asview --> setup["setup()
self.request, self.args, self.kwargs; head = get"] setup --> dispatch["dispatch()
picks get / post / put / delete by HTTP method
else 405 + Allow header"] dispatch --> mixin{"access mixin?
LoginRequiredMixin etc. wrap dispatch()"} mixin -->|"denied"| deny["redirect to login / 403"] mixin -->|"allowed"| handler["get() or post() handler"] handler --> data["get_queryset() / get_object()
get_form_kwargs() / get_form()"] data --> ctx["get_context_data()
adds object_list / object / form / view"] ctx --> render["render_to_response()
returns a TemplateResponse (not yet rendered)"] handler -->|"valid POST"| redir["form_valid()
save + redirect to success URL"] render --> mw["template-response middleware, then
the response is rendered to HTML"] redir --> mw deny --> mw mw --> resp["HTTP response"]
Four consequences follow from this picture and explain most generic-view questions:
-
A view instance lives for one request.
as_view()returns a function that builds a fresh instance each time, so assigning toselfinside a method (self.author = …,self.object = …) is safe and is the idiomatic way to pass data between hooks. Class attributes, by contrast, are shared by every request and every thread; never mutate them (see thequerysetwarning in Single-object mixins: SingleObjectMixin and SingleObjectTemplateResponseMixin). -
setup()runs beforedispatch(). Since Django 2.2View.setup()storesrequest,argsandkwargson the instance (and makesheadan alias ofget). A mixin that needs per-request attributes can overridesetup()and be sure they exist in every handler. RaisingHttp404there happens before an access mixin’sdispatch()check, which is why this page looks things up lazily instead (see thebookproperty in Editing views: FormView, CreateView, UpdateView and DeleteView). -
The response is a
TemplateResponse.render_to_response()does not render the template; it returns a response that is rendered later, after the view has returned. Middleware withprocess_template_response()can still changetemplate_nameorcontext_dataof a generic view’s response. That is also why aQuerySetused in the template is evaluated after the view returns. -
Access mixins run in
dispatch().LoginRequiredMixin,PermissionRequiredMixinandUserPassesTestMixinoverridedispatch(); for an anonymous user the handler,get_queryset()andget_object()never run (the tests in Scoping querysets to request.user assert exactly that).
Which hooks run, in which order
Rather than trust a flowchart, the next script subclasses four generic views so that every hook records its name when it is entered,
and runs each view once with a RequestFactory request. It is the quickest way to see where to override what:
def traced(view_class, names, calls):
"""Return a subclass that records the order in which the named hooks run."""
namespace = {}
for name in names:
def make(name):
def hook(self, *args, **kwargs):
calls.append(name)
return getattr(super(subclass, self), name)(*args, **kwargs)
return hook
namespace[name] = make(name)
subclass = type("Traced" + view_class.__name__, (view_class,), namespace)
return subclass
run("ListView GET", ListView, rf.get("/"), init={"model": Book, "paginate_by": 3})
run("DetailView GET", DetailView, rf.get("/"), slug="alpha", init={"model": Book})
python manage.py shell < ex/trace.pyListView GET -> 200
setup > dispatch > get > get_queryset > get_context_data > get_context_object_name > paginate_queryset > render_to_response > get_template_names
DetailView GET -> 200
setup > dispatch > get > get_object > get_queryset > get_context_data > get_context_object_name > render_to_response > get_template_names
CreateView POST (valid) -> 302
setup > dispatch > post > get_form > get_form_class > get_form_kwargs > form_valid > get_success_url
CreateView POST (invalid) -> 200
setup > dispatch > post > get_form > get_form_class > get_form_kwargs > form_invalid > get_context_data > render_to_response > get_template_names
Read each line as "entered, in this order" — a hook that calls another one (get_context_data calls get_context_object_name, render_to_response calls
get_template_names) appears before its callee. Three observations are worth remembering:
-
ListView.get()callsget_queryset()first andget_context_data()second; pagination (paginate_queryset()) happens insideget_context_data(), which is why overridingget_context_data()is the right place to add data, but not to filter. -
DetailView.get()callsget_object(), which callsget_queryset(); overridingget_queryset()therefore restricts whatget_object()can find, while overridingget_object()runs once per request after the lookup. -
On an invalid POST,
CreateViewcallsform_invalid(), which re-renders the same template with the bound form;get_form()is not called a second time becauseform_invalid()passes the form intoget_context_data().
The method resolution order of the generic views
Generic views are mixin soup, so the inheritance chain is part of the documentation. Printing it is one loop:
from django.views.generic import CreateView, DeleteView, ListView
for cls in (ListView, CreateView, DeleteView):
print(cls.__name__)
for base in cls.__mro__[1:-1]:
print(" ", base.__module__ + "." + base.__name__)
ListView
django.views.generic.list.MultipleObjectTemplateResponseMixin
django.views.generic.base.TemplateResponseMixin
django.views.generic.list.BaseListView
django.views.generic.list.MultipleObjectMixin
django.views.generic.base.ContextMixin
django.views.generic.base.View
CreateView
django.views.generic.detail.SingleObjectTemplateResponseMixin
django.views.generic.base.TemplateResponseMixin
django.views.generic.edit.BaseCreateView
django.views.generic.edit.ModelFormMixin
django.views.generic.edit.FormMixin
django.views.generic.detail.SingleObjectMixin
django.views.generic.base.ContextMixin
django.views.generic.edit.ProcessFormView
django.views.generic.base.View
DeleteView
django.views.generic.detail.SingleObjectTemplateResponseMixin
django.views.generic.base.TemplateResponseMixin
django.views.generic.edit.BaseDeleteView
django.views.generic.edit.DeletionMixin
django.views.generic.edit.FormMixin
django.views.generic.detail.BaseDetailView
django.views.generic.detail.SingleObjectMixin
django.views.generic.base.ContextMixin
django.views.generic.base.View
Two rules follow. The …TemplateResponseMixin classes come first so that get_template_names() and render_to_response() are found
before anything else; and the Base…View that implements get()/post() sits between them and the data mixins. When you add your own mixin,
put it to the left of the generic view (class MyView(OwnReviewsMixin, UpdateView)), so that its get_queryset() runs first and
calls super(). The complete rules, and the diagram of the flow, are on the Class-Based Views page; the flattened index lists every inherited method of every generic view in one place.
Source: Generic display views (each view documents its "Ancestors (MRO)" and "Method Flowchart") and Flattened index
Display views: ListView and DetailView
ListView and DetailView are the most used generic views. The smallest useful ListView is two lines and a template:
class BookListView(ListView):
model = Book
model = Book is shorthand for queryset = Book.objects.all() (using the model’s default manager). Without a template_name, the view looks for
catalog/book_list.html (app label, lower-cased model name, the _list suffix) and renders it with object_list and book_list in the
context. Anything beyond that is configuration: a filtered queryset, an ordering, a friendlier context_object_name, a page size, a
different template_name. The class below is the one the rest of this page uses:
class BookListView(ListView):
queryset = PUBLISHED # a class attribute: get_queryset() clones it for every request
paginate_by = 3
context_object_name = "books" # in addition to the default object_list
def get_queryset(self):
queryset = super().get_queryset()
q = self.request.GET.get("q", "").strip()
if q:
queryset = queryset.filter(
Q(title__icontains=q) | Q(author__name__icontains=q)
)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["q"] = self.request.GET.get("q", "").strip()
return context
{% extends "catalog/base.html" %}
{% block content %}
<h1>Books</h1>
<form method="get"><input name="q" value="{{ q }}"><button>Search</button></form>
<ul>
{% for book in books %}
<li><a href="{{ book.get_absolute_url }}">{{ book.title }}</a> by {{ book.author }} ({{ book.published|date:"Y-m-d" }})</li>
{% empty %}
<li>No books found.</li>
{% endfor %}
</ul>
{% include "catalog/pagination.html" %}
{% endblock %}
{% if is_paginated %}
<p class="pager">
{% if page_obj.has_previous %}<a href="{% querystring page=page_obj.previous_page_number %}">previous</a>{% endif %}
Page {{ page_obj.number }} of {{ paginator.num_pages }}
{% if page_obj.has_next %}<a href="{% querystring page=page_obj.next_page_number %}">next</a>{% endif %}
</p>
{% endif %}
Source: ListView and Built-in class-based generic views
What each part does:
-
queryset = PUBLISHEDfixes the starting set of rows (published books, with their author loaded in the same query byselect_related(), so the template does not run one extra query per row). -
get_queryset()is where request-dependent filtering belongs. Theqsearch parameter narrows the class-level queryset with aQobject; becausesuper().get_queryset()returns a clone, adding filters never mutates the shared class attribute. -
get_context_data()addsqso the search box can redisplay what was typed. Always callsuper()first: the parent buildsobject_list, the pagination variables andview. -
context_object_name = "books"gives template authors a meaningful name;object_listis still there too. Without it the name would bebook_list, derived from the model.
DetailView, lookups and "extra work"
DetailView fetches one object. It reads the pk keyword argument of the URL pattern, or, when that is absent, the slug keyword argument,
and looks it up in get_queryset(); if neither argument is in the URLconf it raises an AttributeError that says so (see Pitfalls and the error messages they produce), and
if no row matches it raises Http404. The names are configurable (pk_url_kwarg, slug_url_kwarg, slug_field), so a route such as
books/<slug:slug>/ needs no code at all:
class BookDetailView(DetailView):
queryset = PUBLISHED.prefetch_related("reviews__reviewer")
# DetailView looks the object up by the URL keyword argument "slug"
# (slug_url_kwarg) against the model field "slug" (slug_field).
def get_object(self, queryset=None):
book = super().get_object(queryset)
# "extra work": an atomic counter update that is safe under concurrency
Book.objects.filter(pk=book.pk).update(views=F("views") + 1)
return book
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["average_rating"] = self.object.reviews.aggregate(
average=Avg("rating")
)["average"]
return context
{% extends "catalog/base.html" %}
{% block title %}{{ book.title }}{% endblock %}
{% block content %}
<h1>{{ book.title }}</h1>
<p>by <a href="{{ book.author.get_absolute_url }}">{{ book.author }}</a>, {{ book.published|date:"Y-m-d" }}, ${{ book.price }}</p>
<p>Average rating: {{ average_rating|default:"none yet" }}</p>
<ul>
{% for review in book.reviews.all %}<li>{{ review.rating }}/5 by {{ review.reviewer }}: {{ review.body }}</li>{% endfor %}
</ul>
<a href="{% url 'catalog:review-add' book.slug %}">Write a review</a>
{% endblock %}
Source: DetailView and Single object mixins
Two idioms are shown here. Overriding get_object() and calling super() is the way to do extra work for the request (a view counter
here; a "last accessed" timestamp in the official topic guide). The counter uses update(views=F("views") + 1) so that two simultaneous
requests do not overwrite each other’s increment, as book.views += 1; book.save() would. It is also a GET that writes to the
database, which is acceptable for a hit counter but not for anything a crawler, a prefetching browser or a cache must not trigger;
state changes belong in POST. Overriding get_context_data() is the way to add derived data, here the average rating.
A tempting but wrong variant of the queryset in a detail view is queryset = Book.objects.all() combined with an is_published check in
the template. Hiding an unpublished book in the template still serves its URL with a 200 status, and anyone with the URL gets the page; putting
the filter in the queryset (as PUBLISHED does) turns it into a 404 before anything is rendered.
Dynamic filtering from the URL
ListView.get_queryset() can use everything that is on self: the request (self.request), positional URL arguments (self.args) and named
ones (self.kwargs). That is how one class serves "the books of any author" without a URL pattern per author:
class AuthorBookListView(ListView):
template_name = "catalog/author_books.html"
context_object_name = "books"
paginate_by = 5
def get_queryset(self):
# self.kwargs holds the captured URL arguments, self.request the request
self.author = get_object_or_404(Author, slug=self.kwargs["slug"])
return PUBLISHED.filter(author=self.author)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["author"] = self.author
return context
{% extends "catalog/base.html" %}
{% block content %}
<h1>Books by {{ author.name }}</h1>
<ul>
{% for book in books %}<li><a href="{{ book.get_absolute_url }}">{{ book.title }}</a></li>{% endfor %}
</ul>
{% include "catalog/pagination.html" %}
{% endblock %}
Source: Dynamic filtering
get_object_or_404() turns an unknown author into a 404 page instead of an empty list, and the author is kept on self so that
get_context_data() can reuse it without a second query. Setting template_name is necessary here: without it the view would look
for catalog/book_list.html, the same template as the unfiltered list, which is often what you want but here has a different heading.
Options of the display views
| Attribute / method | What it controls | Default |
|---|---|---|
|
Which rows. |
none — one of them is required |
|
|
the model’s |
|
The extra context variable holding the list (or the object). |
|
|
Which template(s) to try, in order. |
|
|
Name of a field on the object that holds a template name, tried before the default. |
|
|
When |
|
|
How the object is looked up (see Single-object mixins: SingleObjectMixin and SingleObjectTemplateResponseMixin). |
|
|
Pagination (next section). |
no pagination, |
|
A dict merged into the context; convenient in |
|
extra_context can be passed straight from the URLconf, which is handy for one-off variations of a view without subclassing it
(ListView.as_view(model=Book, extra_context={"title": "All books"})). The same call accepts any other class attribute, which is how
the options script below changes paginate_orphans and page_kwarg without defining a subclass.
Two context-related traps, both documented: the variables a view puts into the context override the ones from context processors,
so a DetailView of the User model, whose default context name is user, would hide the user that the auth context processor
provides — choose another context_object_name in that case; and the context always contains view, the view instance, so
a template can read view.kwargs.slug.
Source: Multiple object mixins, Single object mixins and Simple mixins
Pagination
Setting paginate_by turns on pagination: ListView wraps the queryset in a Paginator, selects the page named by the page
query-string parameter (or a page keyword argument in the URLconf, as in path("objects/page<int:page>/", …)) and adds paginator,
page_obj and is_paginated to the context. Pages are numbered from 1, ?page=last is accepted as a shortcut for the final page,
and any other value that is not a valid page number (abc, 0, a page past the end) is a 404 — not a 500, and not silently page 1. The options are plain
attributes, so as_view() can set them without a subclass:
# paginate_orphans: 7 published books, pages of 3 -> 3 + 3 + 1; with orphans=1 the last
# page absorbs the single leftover book.
for orphans in (0, 1):
view = ListView.as_view(model=Book, paginate_by=3, paginate_orphans=orphans,
template_name="catalog/book_list.html")
response = view(rf.get("/", {"page": "last"}))
ctx = response.context_data
print(f"orphans={orphans}: pages={ctx['paginator'].num_pages}, last page has {len(ctx['page_obj'])} books")
# page_kwarg renames the query-string parameter
view = ListView.as_view(model=Book, paginate_by=3, page_kwarg="p", template_name="catalog/book_list.html")
print("page_kwarg='p' ?p=2 ->", view(rf.get("/", {"p": 2})).context_data["page_obj"].number)
# extra_context straight from as_view()
view = TemplateView.as_view(template_name="catalog/suggest_thanks.html", extra_context={"title": "Hello"})
print("extra_context:", view(rf.get("/")).context_data["title"])
python manage.py shell < ex/options.py (first lines)orphans=0: pages=3, last page has 1 books
orphans=1: pages=2, last page has 4 books
page_kwarg='p' ?p=2 -> 2
extra_context: Hello
Source: MultipleObjectMixin and Pagination
The pagination links in pagination.html use the {% querystring %} template tag (new in Django 5.1) to build ?page=2 while
keeping every other query parameter — here the q of the search form — instead of hand-assembling a URL and forgetting one. On
Django 5.0 or earlier you would write ?q={{ q|urlencode }}&page=… by hand.
Three pitfalls specific to pagination:
-
Order the queryset. Paginating an unordered queryset can show the same row on two pages and skip another, because SQL does not promise any order without
ORDER BY. Django warns about it: paginatingAuthor.objects.all()(noMeta.ordering) printedUnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list: <class 'catalog.models.Author'> QuerySet.TheBookmodel has aMeta.ordering, soBookListViewdoes not warn;UnorderedWarningTestsin Running the examples proves the warning for an unordered queryset. -
Let the client choose the page size only within limits.
get_paginate_by()is the hook; the mixin below clamps whatever?per_page=says so that?per_page=100000cannot make one request load the whole table. -
paginate_bycounts objects, not queries. A page of 3 books with the template readingbook.author.namewould still run a query per row without theselect_related("author")inPUBLISHED; see Query Optimization & Fetch Modes.
class PerPageMixin:
"""Let the client pick the page size with ?per_page=, within sane limits."""
max_per_page = 50
def get_paginate_by(self, queryset):
try:
requested = int(self.request.GET.get("per_page", self.paginate_by))
except (TypeError, ValueError):
requested = self.paginate_by
return max(1, min(requested, self.max_per_page))
class FlexibleBookListView(PerPageMixin, BookListView):
pass
Django 6.0 deprecated an orphans value greater than or equal to the page size (paginate_orphans >= paginate_by), because such a page layout is ambiguous; keep orphans smaller than the page size.
If the paginator class does not share the constructor of django.core.paginator.Paginator, override get_paginator() as well as
setting paginator_class. Django 6.0 added AsyncPaginator and AsyncPage for async code, but the generic views use the synchronous
Paginator; see Sync and async behaviour.
Source: Multiple object mixins and Paginator
Tests for the display views
The tests use Django’s test client, so they exercise the URLconf, the view, the template and the middleware together. They assert status codes, the template that was used, and the context — not just "200":
class BookshelfTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.alice = User.objects.create_user("alice", password="pw-alice-1")
cls.bob = User.objects.create_user("bob", password="pw-bob-1")
cls.ada = Author.objects.create(name="Ada Writer", slug="ada-writer")
cls.bo = Author.objects.create(name="Bo Novelist", slug="bo-novelist")
specs = [
("Alpha", D(2024, 1, 10), cls.ada),
("Bravo", D(2024, 1, 11), cls.ada),
("Charlie", D(2024, 5, 20), cls.bo),
("Delta", D(2024, 5, 21), cls.bo),
("Echo", D(2025, 3, 3), cls.ada),
("Foxtrot", D(2025, 3, 4), cls.bo),
("Golf", D(2025, 12, 31), cls.ada),
]
cls.books = {}
for i, (title, published, author) in enumerate(specs):
cls.books[title] = Book.objects.create(
title=title, slug=title.lower(), isbn=f"978000000{i:04d}",
author=author, price="9.99", published=published,
)
cls.hidden = Book.objects.create(
title="Hidden", slug="hidden", isbn="9780000009999", author=cls.ada,
price="1.00", published=D(2025, 6, 1), is_published=False,
)
cls.future = Book.objects.create(
title="Future", slug="future", isbn="9780000008888", author=cls.bo,
price="1.00", published=datetime.date.today() + datetime.timedelta(days=30),
)
cls.review_a = Review.objects.create(
book=cls.books["Alpha"], reviewer=cls.alice, rating=5, body="Great")
cls.review_b = Review.objects.create(
book=cls.books["Alpha"], reviewer=cls.bob, rating=3, body="Fine")
class DisplayViewTests(BookshelfTestCase):
def test_list_is_paginated_and_hides_unpublished(self):
response = self.client.get(reverse("catalog:book-list"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "catalog/book_list.html")
self.assertEqual(len(response.context["books"]), 3)
self.assertEqual(response.context["paginator"].count, 8) # 7 + Future; no Hidden
self.assertNotContains(response, "Hidden")
def test_page_last_and_bad_page(self):
url = reverse("catalog:book-list")
self.assertEqual(self.client.get(url, {"page": "last"}).status_code, 200)
self.assertEqual(self.client.get(url, {"page": "99"}).status_code, 404)
self.assertEqual(self.client.get(url, {"page": "abc"}).status_code, 404)
self.assertEqual(self.client.get(url, {"page": "0"}).status_code, 404)
def test_search_filters_by_title_or_author(self):
response = self.client.get(reverse("catalog:book-list"), {"q": "bo nov"})
# Charlie, Delta, Foxtrot and Future are by Bo Novelist
self.assertEqual(response.context["paginator"].count, 4)
self.assertEqual(response.context["q"], "bo nov")
def test_detail_by_slug_counts_views(self):
url = reverse("catalog:book-detail", kwargs={"slug": "alpha"})
response = self.client.get(url)
self.assertEqual(response.context["book"], self.books["Alpha"])
self.assertEqual(response.context["object"], self.books["Alpha"])
self.assertEqual(response.context["average_rating"], 4)
self.client.get(url)
self.books["Alpha"].refresh_from_db()
self.assertEqual(self.books["Alpha"].views, 2)
def test_detail_404_for_unpublished(self):
url = reverse("catalog:book-detail", kwargs={"slug": "hidden"})
self.assertEqual(self.client.get(url).status_code, 404)
def test_dynamic_filtering_from_url(self):
response = self.client.get(reverse("catalog:author-books", kwargs={"slug": "bo-novelist"}))
self.assertEqual(response.context["author"], self.bo)
self.assertEqual(
{b.title for b in response.context["books"]}, {"Charlie", "Delta", "Foxtrot", "Future"})
missing = reverse("catalog:author-books", kwargs={"slug": "nobody"})
self.assertEqual(self.client.get(missing).status_code, 404)
Source: Testing tools
Editing views: FormView, CreateView, UpdateView and DeleteView
Form handling has three paths: the first GET shows an empty (or pre-filled) form, a POST with invalid data shows the form again
with errors, and a POST with valid data does the work and redirects. Writing that if/else by hand in every function view is the
boilerplate the editing views remove. They split it into overridable steps:
get_form(): get_form_class() + get_form_kwargs()
(initial, prefix, instance)"] getform --> render["render_to_response()
template with 'form'"] post["POST (or PUT)"] --> postform["get_form()
kwargs include request.POST and request.FILES"] postform --> valid{"form.is_valid()?"} valid -->|"no"| invalid["form_invalid(form)
re-render, status 200"] valid -->|"yes"| fv["form_valid(form)
ModelFormMixin: self.object = form.save()"] fv --> url["get_success_url()
success_url, or object.get_absolute_url()"] url --> redirect["HttpResponseRedirect (302)"] invalid --> render
form_valid() is the hook you will override most: it is called only with a valid form and must return an HttpResponse. The base
implementations are short — FormMixin.form_valid() redirects to get_success_url(), and ModelFormMixin.form_valid() first saves the
form (self.object = form.save()) and then does the same. That is why an override calls super().form_valid(form) last (to save and redirect) and does
its own work first or after, depending on whether it needs self.object.
FormView: a form that is not a model
FormView renders any django.forms.Form. Set template_name (there is no model to derive one from), form_class and success_url.
This view is the suggestion box of the Bookshelf: a logged-in reader proposes a book and the curators get an email.
class SuggestBookView(LoginRequiredMixin, FormView):
template_name = "catalog/suggestion_form.html"
form_class = SuggestionForm
success_url = reverse_lazy("catalog:suggest-thanks")
def get_initial(self):
initial = super().get_initial()
initial["title"] = self.request.GET.get("title", "")
return initial
def form_valid(self, form):
form.send_email(suggested_by=self.request.user.get_username())
messages.success(self.request, "Thanks, the curators will take a look.")
return super().form_valid(form) # redirects to success_url
{% extends "catalog/base.html" %}
{% block content %}
<h1>Suggest a book</h1>
<form method="post">{% csrf_token %}
{{ form.as_p }}
<button>Send</button>
</form>
{% endblock %}
-
success_urlusesreverse_lazy()because the attribute is evaluated when the module is imported, before the URLconf is loaded;reverse()there would fail. Alternatively overrideget_success_url(), which runs per request and can usereverse(). -
get_initial()pre-fills the form from the query string (/catalog/suggest/?title=Dune); it returns a copy of theinitialdict, so mutate the copy, not the class attribute. -
form_valid()sends the mail and queues a flash message with the messages framework (see Sessions, Cookies, Messages & Signing), then returnssuper().form_valid(form), which issues the redirect. Redirecting after a successfulPOST(the "post/redirect/get" pattern) means a browser refresh does not re-send the form. -
{% csrf_token %}is in the template. The defaultCsrfViewMiddlewarerejects aPOSTwithout it. -
The thanks page is a
TemplateViewconfigured in the URLconf withtemplate_name, as no logic is needed. -
In the email,
from_email=NonemeansDEFAULT_FROM_EMAIL. Which mail backend delivers it, and the Django 6.1MAILERSsetting, belong to the email page; the tests use the in-memory backend that Django’s test runner installs automatically.
FormMixin has more hooks than this view needs. get_form_class() picks the class dynamically (a different form for staff and readers);
get_form_kwargs() is where to pass extra constructor arguments such as request.user (the idiom used by ReviewForm below);
get_form() instantiates the form and is the place to change a widget or label; prefix namespaces field names when two forms share a page;
and form_invalid() can be overridden to add a message or change the status code.
class FormViewTests(BookshelfTestCase):
def test_login_required_then_valid_post_sends_mail_and_redirects(self):
url = reverse("catalog:suggest")
self.assertRedirects(
self.client.get(url), f"/accounts/login/?next={url}", fetch_redirect_response=False)
self.client.force_login(self.alice)
self.assertEqual(self.client.get(url, {"title": "Dune"}).context["form"].initial, {"title": "Dune"})
response = self.client.post(
url, {"title": "Dune", "author_name": "F. Herbert", "reason": "Classic"})
self.assertRedirects(response, reverse("catalog:suggest-thanks"))
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "Book suggestion: Dune")
def test_invalid_post_redisplays_form_with_errors(self):
self.client.force_login(self.alice)
response = self.client.post(reverse("catalog:suggest"), {"title": "", "author_name": "x"})
self.assertEqual(response.status_code, 200)
self.assertIn("title", response.context["form"].errors)
self.assertEqual(len(mail.outbox), 0)
Source: Basic forms and Testing tools
CreateView and UpdateView: model forms
CreateView and UpdateView build a ModelForm for you. The class is chosen like this: if form_class is set it is used; otherwise
fields is required and Django generates a form for the model found from model, from the object returned by get_object() or from queryset.
The two ways are mutually exclusive:
class BookCreateView(PermissionRequiredMixin, CreateView):
permission_required = "catalog.add_book"
model = Book
fields = ["title", "slug", "isbn", "author", "price", "published", "is_published"]
-
fieldsis required when Django generates the form. A model form view withoutfields(and withoutform_class) raisesImproperlyConfigured— the same as writing aModelFormwithoutMeta.fields. List the fields explicitly; never usefields = "all", because the day someone adds anis_staff-like column to the model it becomes writable from the browser (mass assignment). -
fieldstogether withform_classis an error (ImproperlyConfigured) since Django 1.8; before thatfieldswas silently ignored, which is how stale tutorials ended up with forms that did not match theirfieldslist. -
form_classstill needsmodel: the documentation says to specify it even when theform_classis aModelFormthat already knows its model. -
The redirect: with no
success_url,ModelFormMixin.get_success_url()callsget_absolute_url()on the saved object (Book.get_absolute_url()here), and raisesImproperlyConfiguredif neither exists. Asuccess_urlstring may contain{field}placeholders that are filled withstr.format()from the saved object’s attributes:"/catalog/authors/{slug}/?new=1"redirects to/catalog/authors/cy-poet/?new=1. (Django 1.8 introduced the{}syntax; the old%(field)sform was deprecated then and removed in 1.10, so tutorials that still show it are very old.) Only plain attributes (slug,author_id) are available, not related objects. -
Templates: both views use
<app>/<model>_form.html; settemplate_name_suffix(for example"_update_form") ortemplate_namefor a separate template. -
self.objectisNonewhile aCreateViewshows its form and is set to the saved instance inform_valid(); anUpdateViewsets it inget()andpost()before building the form.
The script that checked the redirect rules:
# query_pk_and_slug: both must match
book = Book.objects.get(slug="alpha")
for slug in ("alpha", "bravo"):
view = DetailView.as_view(model=Book, query_pk_and_slug=True)
try:
print(f"pk={book.pk} slug={slug!r}:", view(rf.get("/"), pk=book.pk, slug=slug).status_code)
except Exception as exc:
print(f"pk={book.pk} slug={slug!r}:", type(exc).__name__)
# success_url placeholders are str.format()ed against the saved object's __dict__
view = CreateView.as_view(model=Author, fields=["name", "slug"], success_url="/catalog/authors/{slug}/?new=1")
response = view(rf.post("/", {"name": "Cy Poet", "slug": "cy-poet"}))
print("success_url ->", response.status_code, response["Location"])
Author.objects.filter(slug="cy-poet").delete()
view = CreateView.as_view(model=Author, fields=["name", "slug"])
response = view(rf.post("/", {"name": "Cy Poet", "slug": "cy-poet"}))
print("get_absolute_url ->", response.status_code, response["Location"])
Author.objects.filter(slug="cy-poet").delete()
pk=1 slug='alpha': 200
pk=1 slug='bravo': Http404
success_url -> 302 /catalog/authors/cy-poet/?new=1
get_absolute_url -> 302 /catalog/authors/cy-poet/
Source: ModelFormMixin and Model forms
Setting the owner in form_valid() and get_form_kwargs()
Fields that the user must not choose — the author of a review, the book it is about — are not on the form; the view fills them in
before the object is saved. The official idiom is to assign to form.instance in form_valid() and then call super(), which saves:
class JsonableResponseMixin:
"""Add JSON support to an object-based form view such as CreateView."""
def wants_json(self):
preferred = self.request.get_preferred_type(["text/html", "application/json"])
return preferred == "application/json"
def form_invalid(self, form):
response = super().form_invalid(form)
if self.wants_json():
return JsonResponse(form.errors, status=400)
return response
def form_valid(self, form):
response = super().form_valid(form) # saves the object, sets self.object
if self.wants_json():
return JsonResponse({"pk": self.object.pk}, status=201)
return response
class ReviewCreateView(
LoginRequiredMixin, JsonableResponseMixin, SuccessMessageMixin, CreateView
):
model = Review
form_class = ReviewForm
@cached_property
def book(self):
return get_object_or_404(PUBLISHED, slug=self.kwargs["slug"])
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(book=self.book, reviewer=self.request.user)
return kwargs
def form_valid(self, form):
# ModelFormMixin.form_valid() saves form.instance, so fill in the fields that
# are not on the form first. Never put them in `fields` and trust the browser.
form.instance.book = self.book
form.instance.reviewer = self.request.user
return super().form_valid(form)
def get_success_message(self, cleaned_data):
return f"Your review of {self.book.title} was saved."
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["book"] = self.book
return context
{% extends "catalog/base.html" %}
{% block content %}
<h1>{% if object %}Edit your review of {{ object.book }}{% else %}Review {{ book }}{% endif %}</h1>
<form method="post">{% csrf_token %}{{ form.as_p }}<button>Save</button></form>
{% endblock %}
Reading the class from the top:
-
The base-class order is deliberate.
LoginRequiredMixinmust be leftmost so itsdispatch()runs first;JsonableResponseMixinandSuccessMessageMixinwrapform_valid()/form_invalid()and so must precedeCreateView, whose versions they extend withsuper(). -
bookis acached_property, not a lookup insetup(). It runs on first use — inget_form_kwargs()orform_valid()— which is after the login check, and only once per request even though several hooks read it. AHttp404for an unknown slug therefore cannot leak whether a book exists to anonymous visitors, and no query runs for them at all. -
get_form_kwargs()handsbookandreviewertoReviewForm, whoseclean()turns a second review of the same book into an ordinary non-field error instead of anIntegrityErrorand a 500. (TheUniqueConstraintis the backstop. A constraint on fields that are not on the form is not checked byModelFormvalidation, becausevalidate_unique()andvalidate_constraints()skip excluded fields; that is why the check is inclean().) Passingrequest.userto the form this way, rather than reaching intorequestfrom the form, keeps the form testable. -
form_valid()assignsform.instance.bookandform.instance.reviewer, so a hostile browser cannot choose them: even a postedreviewer=2is ignored because it is not infields(thetest_posted_reviewer_field_is_ignoredtest below posts it). -
form_valid()assigns toform.instanceand then callssuper().form_valid(form), which saves once and redirects. Do not callform.save()yourself and thensuper().form_valid(form): the base class saves a second time (an extraUPDATEand a second round ofsavesignals). If you want full control, useself.object = form.save(commit=False), set the fields, callself.object.save()andform.save_m2m()(needed for many-to-many fields whencommit=False), and return your own redirect without callingsuper().form_valid(). -
SuccessMessageMixinqueues a flash message after a valid form. Itssuccess_messageis formatted with%and the form’scleaned_data, so a literal percent sign in the message would raise; for text that uses the saved object, overrideget_success_message(), as above. -
get_context_data()addsbookso the template can say what is being reviewed.
Content negotiation: one view for browsers and for API clients
JsonableResponseMixin is the documented "same form, two clients" pattern. It returns a JSON response (400 with the form errors, or 201
with the new primary key) when the client prefers JSON, and the normal HTML behaviour otherwise. Django 5.2 added
HttpRequest.get_preferred_type(), which parses the Accept header including quality values and returns the best of the types you
offer. That is more reliable than the request.accepts("text/html") test of the older documentation example: accepts() is True for a client
that sends no Accept header or /, and a browser that asks for text/css,/;q=0.1 is "accepting" HTML as far as accepts() is concerned. For anything beyond
a couple of formats, use Django REST Framework or Django Ninja (a later page) rather than growing this mixin.
class EditingViewTests(BookshelfTestCase):
def test_create_review_sets_reviewer_and_book(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-add", kwargs={"slug": "bravo"})
response = self.client.post(url, {"rating": 4, "body": "Nice"}, follow=True)
review = Review.objects.get(book=self.books["Bravo"])
self.assertEqual((review.reviewer, review.rating), (self.alice, 4))
self.assertRedirects(response, self.books["Bravo"].get_absolute_url())
self.assertContains(response, "Your review of Bravo was saved.")
def test_posted_reviewer_field_is_ignored(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-add", kwargs={"slug": "bravo"})
self.client.post(url, {"rating": 4, "reviewer": self.bob.pk, "book": 1})
self.assertEqual(Review.objects.get(book=self.books["Bravo"]).reviewer, self.alice)
def test_duplicate_review_is_a_form_error_not_a_500(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-add", kwargs={"slug": "alpha"})
response = self.client.post(url, {"rating": 1})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["form"].non_field_errors(), ["You have already reviewed this book."])
def test_rating_range_comes_from_the_model(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-add", kwargs={"slug": "bravo"})
response = self.client.post(url, {"rating": 9})
self.assertIn("rating", response.context["form"].errors)
def test_anonymous_is_redirected_to_login(self):
url = reverse("catalog:review-add", kwargs={"slug": "bravo"})
self.assertRedirects(
self.client.post(url, {"rating": 4}), f"/accounts/login/?next={url}",
fetch_redirect_response=False)
self.assertFalse(Review.objects.filter(book=self.books["Bravo"]).exists())
def test_json_negotiation(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-add", kwargs={"slug": "bravo"})
bad = self.client.post(url, {"rating": 9}, headers={"accept": "application/json"})
self.assertEqual(bad.status_code, 400)
self.assertIn("rating", bad.json())
ok = self.client.post(url, {"rating": 4}, headers={"accept": "application/json"})
self.assertEqual(ok.status_code, 201)
self.assertEqual(ok.json(), {"pk": Review.objects.get(book=self.books["Bravo"]).pk})
# a browser (Accept: text/html,...) still gets the redirect
self.client.logout(); self.client.force_login(self.bob)
html = self.client.post(url, {"rating": 2}, headers={"accept": "text/html,*/*;q=0.8"})
self.assertEqual(html.status_code, 302)
def test_permission_required_distinguishes_anonymous_and_denied(self):
url = reverse("catalog:book-add")
self.assertEqual(self.client.get(url).status_code, 302) # anonymous: login redirect
self.client.force_login(self.alice)
self.assertEqual(self.client.get(url).status_code, 403) # authenticated, no perm
self.alice.user_permissions.add(Permission.objects.get(codename="add_book"))
self.client.force_login(User.objects.get(pk=self.alice.pk)) # drop the permission cache
self.assertEqual(self.client.get(url).status_code, 200)
response = self.client.post(url, {
"title": "New", "slug": "new", "isbn": "9781111111111", "author": self.ada.pk,
"price": "5.00", "published": "2025-01-01", "is_published": "on"})
self.assertRedirects(response, "/catalog/books/new/", fetch_redirect_response=False)
Source: Content negotiation example and Django 5.2 release notes
The test client sends headers={"accept": "application/json"} (the headers argument is available since Django 4.2); a normal
Accept: text/html,/;q=0.8 browser header still gets a 302. The PermissionRequiredMixin test shows the two denial modes: an
anonymous user is redirected to login (302), an authenticated user without the permission gets 403.
UpdateView and DeleteView on the user’s own data
UpdateView is CreateView with self.object loaded first; it shares the _form template. DeleteView shows a confirmation page on GET
and deletes on POST; since Django 4.0 it is a FormMixin view, which has three consequences. First, form_class defaults to an empty form, but you can
provide your own — here ConfirmDeleteForm, a form with a required checkbox — and an unchecked box re-displays the page. Second, deletion happens in
form_valid(), so SuccessMessageMixin works with DeleteView, and any custom deletion logic written in a delete() override for an older Django (for instance
"archive instead of delete") must move into form_valid() or a shared helper. Third, success_url is required (there is
no object left to call get_absolute_url() on), and it accepts the same {field} placeholders.
Both views are shown in the next section together with their scoping mixin, because the interesting part is which object a user may touch.
{% extends "catalog/base.html" %}
{% block content %}
<h1>Delete review</h1>
<form method="post">{% csrf_token %}
<p>Delete your review of "{{ object.book }}"?</p>
{{ form.as_p }}
<button>Delete</button>
</form>
{% endblock %}
Source: DeleteView, DeletionMixin and Django 4.0 release notes
Scoping querysets to request.user
The commonest security bug in a generic edit view is not a missing login check; it is that the object is looked up by a primary key from the URL and
any logged-in user may pass any key. /catalog/my-reviews/17/edit/ then edits review 17 whoever wrote it — an insecure direct object reference.
The generic views give a one-line cure, because every one of them finds its objects through get_queryset(): restrict that, and the list, the
detail lookup, the update form and the delete confirmation are all restricted in one place.
class OwnReviewsMixin(LoginRequiredMixin):
"""Restrict every lookup to the reviews written by the current user."""
def get_queryset(self):
return super().get_queryset().filter(reviewer=self.request.user)
class MyReviewListView(OwnReviewsMixin, ListView):
model = Review
context_object_name = "reviews"
paginate_by = 10
def get_queryset(self):
return super().get_queryset().select_related("book__author")
class ReviewUpdateView(OwnReviewsMixin, SuccessMessageMixin, UpdateView):
model = Review
form_class = ReviewForm
success_message = "Your review was updated."
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(book=self.object.book, reviewer=self.request.user)
return kwargs
class ReviewDeleteView(OwnReviewsMixin, SuccessMessageMixin, DeleteView):
model = Review
form_class = ConfirmDeleteForm
success_url = reverse_lazy("catalog:my-reviews")
success_message = "Your review was deleted."
Source: Dynamic filtering and SingleObjectMixin.get_queryset()
How it works:
-
OwnReviewsMixinextendsLoginRequiredMixin, so a single base class gives both rules, and itsget_queryset()callssuper().get_queryset()before adding.filter(reviewer=self.request.user). Because the generic view’s ownget_queryset()is next in the MRO (the mixin is listed first), themodelorquerysetattribute still applies. -
get_object()runsget_queryset().get(pk=…); for another user’s review that isDoesNotExist, which the generic view turns intoHttp404. The other user’s review is not found, not "forbidden": a 403 would confirm that review 17 exists. -
The list view reuses the mixin and only adds
select_related("book__author"), so the template can print book titles without one query per row. -
An anonymous request never reaches
get_queryset():LoginRequiredMixin.dispatch()redirects first, soself.request.useris a real user inside every handler and the filter never receives anAnonymousUser. That only holds when the access mixin is the leftmost base class. -
ReviewUpdateViewuses the sameReviewFormas the create view and passes the book of the existing review throughget_form_kwargs().self.objectis already loaded whenget_form_kwargs()runs for anUpdateView. -
ReviewDeleteViewcombines the scoping mixin withSuccessMessageMixinand a confirmation form; itssuccess_urlusesreverse_lazy()for the same import-time reason as before.
{% extends "catalog/base.html" %}
{% block content %}
<h1>My reviews</h1>
<ul>
{% for review in reviews %}
<li>{{ review.book }}: {{ review.rating }}/5
<a href="{% url 'catalog:review-edit' review.pk %}">edit</a>
<a href="{% url 'catalog:review-delete' review.pk %}">delete</a></li>
{% empty %}<li>You have not written any reviews.</li>{% endfor %}
</ul>
{% include "catalog/pagination.html" %}
{% endblock %}
class OwnerScopingTests(BookshelfTestCase):
def test_list_shows_only_my_reviews(self):
self.client.force_login(self.alice)
response = self.client.get(reverse("catalog:my-reviews"))
self.assertEqual(list(response.context["reviews"]), [self.review_a])
def test_other_users_review_is_404_for_update_and_delete(self):
self.client.force_login(self.bob)
for name in ("catalog:review-edit", "catalog:review-delete"):
url = reverse(name, kwargs={"pk": self.review_a.pk})
self.assertEqual(self.client.get(url).status_code, 404, name)
self.assertEqual(self.client.post(url, {"rating": 1, "confirm": "on"}).status_code, 404, name)
self.review_a.refresh_from_db()
self.assertEqual(self.review_a.rating, 5)
def test_update_own_review(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-edit", kwargs={"pk": self.review_a.pk})
response = self.client.post(url, {"rating": 2, "body": "Changed my mind"}, follow=True)
self.review_a.refresh_from_db()
self.assertEqual((self.review_a.rating, self.review_a.body), (2, "Changed my mind"))
self.assertContains(response, "Your review was updated.")
def test_delete_needs_the_confirmation_checkbox(self):
self.client.force_login(self.alice)
url = reverse("catalog:review-delete", kwargs={"pk": self.review_a.pk})
self.assertContains(self.client.get(url), "Yes, delete this review")
self.client.post(url, {}) # checkbox missing: form invalid, nothing deleted
self.assertTrue(Review.objects.filter(pk=self.review_a.pk).exists())
response = self.client.post(url, {"confirm": "on"}, follow=True)
self.assertFalse(Review.objects.filter(pk=self.review_a.pk).exists())
self.assertRedirects(response, reverse("catalog:my-reviews"))
self.assertContains(response, "Your review was deleted.")
def test_anonymous_never_reaches_get_queryset(self):
url = reverse("catalog:review-delete", kwargs={"pk": self.review_a.pk})
self.assertEqual(self.client.post(url, {"confirm": "on"}).status_code, 302)
self.assertTrue(Review.objects.filter(pk=self.review_a.pk).exists())
These tests are written so that they cannot pass for the wrong reason: the "other user" tests assert 404 for both GET and POST
and then re-read the row to check it was not changed; the delete test posts without the checkbox first and checks the row survived, then posts
with it and checks the row is gone; the anonymous test checks the row is still there. A test that only posts and asserts "status 200" would pass
on a view that did nothing.
Source: Testing tools and Using the Django authentication system
The access-control mixins
The scoping mixin builds on the access mixins of django.contrib.auth.mixins, which exist since Django 1.9. They all derive from
AccessMixin, so they share the same attributes, and each overrides dispatch().
| Mixin | Behaviour |
|---|---|
|
Anonymous users are redirected to the login page (or get a 403 when |
|
Set |
|
Override |
|
The base: |
An authenticated user who fails a permission or test check always gets a 403, never a redirect to login (that would loop for a user who is already logged
in). For an object-level rule that is not "the owner" — an editor may change reviews of their section, say — a UserPassesTestMixin.test_func() that reads
self.get_object() works but fetches the object twice; overriding get_queryset() to include the allowed rows is usually simpler and safer, because
it protects every handler at once.
Two newer facts. Django 5.1 added LoginRequiredMiddleware, which makes every view require login unless it is decorated with login_not_required() (the authentication page covers both). The mixin is still the right tool when the rule is per view
or needs permission_required; the middleware is a safety net so that a new view cannot be public by mistake. And permissions
are cached on the user object, which is why the test above reloads the user after adding a permission.
Pitfalls of access control in class-based views
-
Order of the bases.
class V(CreateView, LoginRequiredMixin)puts the access check after the generic view’sdispatch(); the check never runs. Mixins on the left, the generic view on the right. -
A hand-written check before the mixin. Overriding
dispatch()in your view, or adding a mixin whosedispatch()readsrequest.user.profile, to the left ofLoginRequiredMixinmeans your code runs for anonymous users first and crashes with anAttributeErroronAnonymousUser. Any extra mixin that usesrequest.usermust come afterLoginRequiredMixin. -
Filtering after the fact.
get_object()thenif obj.reviewer != request.user: raise PermissionDeniedis correct but only for that one handler, and leaks existence with a 403. Filter the queryset. -
DELETEskips the form.DeletionMixin.delete()is still a handler ofDeleteView: an HTTPDELETErequest deletes the object without validatingConfirmDeleteForm(and without the success message), because only thePOSThandler runs the form. The scoping still holds, sincedelete()also callsget_object()(thetest_http_delete_skips_the_confirmation_form_but_not_the_scopingtest pins both facts down). If the confirmation form is a real business rule, not just UX, restricthttp_method_namesto["get", "post", "head", "options"]. -
Guessable keys. Sequential primary keys let anyone enumerate objects that are meant to be reachable only by whoever has the link.
query_pk_and_slug = Truemakesget_object()match both the primary key and the slug (the script above shows a mismatched slug giving a 404), and a non-sequentialslugor UUID key hides the rest. This is a mitigation, not authorization: scoping the queryset is. -
CSRF. All the state-changing views use
POSTand the templates contain{% csrf_token %}. A "delete" link that is a plain<a href>to a URL that deletes onGETis a CSRF hole;DeleteViewdoes not delete onGET.
class PitfallTests(BookshelfTestCase):
def test_per_page_is_clamped(self):
url = reverse("catalog:flexible-list")
self.assertEqual(len(self.client.get(url).context["books"]), 3)
self.assertEqual(len(self.client.get(url, {"per_page": 5}).context["books"]), 5)
self.assertEqual(len(self.client.get(url, {"per_page": 5000}).context["books"]), 8)
self.assertEqual(len(self.client.get(url, {"per_page": "-3"}).context["books"]), 1)
self.assertEqual(len(self.client.get(url, {"per_page": "x"}).context["books"]), 3)
def test_http_delete_skips_the_confirmation_form_but_not_the_scoping(self):
self.client.force_login(self.bob)
url = reverse("catalog:review-delete", kwargs={"pk": self.review_a.pk})
self.assertEqual(self.client.delete(url).status_code, 404) # alice's review
mine = reverse("catalog:review-delete", kwargs={"pk": self.review_b.pk})
self.assertEqual(self.client.delete(mine).status_code, 302) # no checkbox needed
self.assertFalse(Review.objects.filter(pk=self.review_b.pk).exists())
def test_head_and_options(self):
url = reverse("catalog:book-list")
self.assertEqual(self.client.head(url).status_code, 200)
self.assertEqual(self.client.options(url)["Allow"], "GET, HEAD, OPTIONS")
self.assertEqual(self.client.post(url).status_code, 405)
def test_restricting_http_method_names_rejects_delete(self):
from .views import ReviewDeleteView
request = RequestFactory().delete("/")
request.user = self.alice
view = ReviewDeleteView.as_view(http_method_names=["get", "post", "head", "options"])
self.assertEqual(view(request, pk=self.review_a.pk).status_code, 405)
self.assertTrue(Review.objects.filter(pk=self.review_a.pk).exists())
def test_list_mixin_reused_without_templates(self):
response = self.client.get(reverse("catalog:book-json"), {"page": 2})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual((data["page"], data["pages"]), (2, 3))
self.assertEqual([r["title"] for r in data["results"]], ["Echo", "Delta", "Charlie"])
self.assertEqual(self.client.get(reverse("catalog:book-json"), {"page": 9}).status_code, 404)
Date-based views
Seven views in django.views.generic.dates turn a model with a date into a drill-down archive: an index of the latest items and the years that have
them, then year, month, week and day pages, a "today" page, and a detail page that includes the date in its URL (as in
/archive/2024/01/10/alpha/). They all need one thing, date_field, the name of the DateField or DateTimeField to group by, and a
queryset or model. Everything else has a default.
class BookArchiveIndexView(ArchiveIndexView):
queryset = PUBLISHED
date_field = "published"
date_list_period = "year" # the default; "month" and "day" are the alternatives
paginate_by = 5
class BookYearArchiveView(YearArchiveView):
queryset = PUBLISHED
date_field = "published"
make_object_list = True # also put the year's books in object_list
class BookMonthArchiveView(MonthArchiveView):
queryset = PUBLISHED
date_field = "published"
month_format = "%m" # URL captures 05, not "may"
class BookWeekArchiveView(WeekArchiveView):
queryset = PUBLISHED
date_field = "published"
week_format = "%V" # ISO 8601 week number...
year_format = "%G" # ...which requires the ISO year
class BookDayArchiveView(DayArchiveView):
queryset = PUBLISHED
date_field = "published"
month_format = "%m"
class BookTodayArchiveView(TodayArchiveView):
queryset = PUBLISHED
date_field = "published"
class BookDateDetailView(DateDetailView):
queryset = PUBLISHED
date_field = "published"
month_format = "%m"
Each view reads its date components from the URL (see the archive/… patterns in the URLconf at the top of the page), which is why the
route parameters are called year, month, day and week. The five templates, with the context each view provides:
{% extends "catalog/base.html" %}
{% block content %}
<h1>Latest books</h1>
<ul>{% for year in date_list %}<li><a href="{% url 'catalog:archive-year' year.year %}">{{ year.year }}</a></li>{% endfor %}</ul>
<ul>{% for book in latest %}<li>{{ book.published|date:"Y-m-d" }}: {{ book.title }}</li>{% endfor %}</ul>
{% include "catalog/pagination.html" %}
{% endblock %}
{% extends "catalog/base.html" %}
{% block content %}
<h1>Books of {{ year|date:"Y" }}</h1>
<ul>{% for month in date_list %}<li><a href="{% url 'catalog:archive-month' month.year month.month %}">{{ month|date:"F" }}</a></li>{% endfor %}</ul>
<ul>{% for book in object_list %}<li>{{ book.published|date:"Y-m-d" }}: {{ book.title }}</li>{% endfor %}</ul>
<p>
{% if previous_year %}<a href="{% url 'catalog:archive-year' previous_year.year %}">{{ previous_year|date:"Y" }}</a>{% endif %}
{% if next_year %}<a href="{% url 'catalog:archive-year' next_year.year %}">{{ next_year|date:"Y" }}</a>{% endif %}
</p>
{% endblock %}
{% extends "catalog/base.html" %}
{% block content %}
<h1>Books of {{ month|date:"F Y" }}</h1>
<ul>{% for day in date_list %}<li><a href="{% url 'catalog:archive-day' day.year day.month day.day %}">{{ day|date:"j" }}</a></li>{% endfor %}</ul>
<ul>{% for book in object_list %}<li>{{ book.published|date:"Y-m-d" }}: {{ book.title }}</li>{% endfor %}</ul>
<p>
{% if previous_month %}Previous: {{ previous_month|date:"F Y" }}{% endif %}
{% if next_month %}Next: {{ next_month|date:"F Y" }}{% endif %}
</p>
{% endblock %}
{% extends "catalog/base.html" %}
{% block content %}
<h1>Week starting {{ week|date:"Y-m-d" }}</h1>
<ul>{% for book in object_list %}<li>{{ book.published|date:"Y-m-d" }}: {{ book.title }}</li>{% endfor %}</ul>
<p>
{% if previous_week %}Previous week starts {{ previous_week|date:"Y-m-d" }}{% endif %}
{% if next_week %}Next week starts {{ next_week|date:"Y-m-d" }}{% endif %}
</p>
{% endblock %}
{% extends "catalog/base.html" %}
{% block content %}
<h1>{{ day|date:"Y-m-d" }}</h1>
<ul>{% for book in object_list %}<li><a href="{% url 'catalog:archive-detail' book.published.year book.published.month book.published.day book.slug %}">{{ book.title }}</a></li>{% endfor %}</ul>
<p>
{% if previous_day %}Previous: {{ previous_day|date:"Y-m-d" }}{% endif %}
{% if next_day %}Next: {{ next_day|date:"Y-m-d" }}{% endif %}
</p>
{% endblock %}
DateDetailView reuses book_detail.html (its suffix is _detail, like DetailView), and TodayArchiveView reuses book_archive_day.html (its suffix is
_archive_day; the test below asserts it).
Source: Generic date views and Date-based mixins
What each view provides
| View | date_list holds |
Other context |
|---|---|---|
|
every year with objects (newest first); |
|
|
every month of the year with objects (oldest first) |
|
|
every day of the month with objects |
|
|
not provided |
|
|
not provided |
|
|
not provided |
as |
|
not provided |
|
The next_* and previous_* values are the first day of the adjacent period, and they are None when there is nothing to link to. With the
default allow_empty = False they skip empty periods and point at the next period that has objects, so a link is never a 404; the template can
use {% if next_month %} to hide a missing link. In the test below, next_year of 2024 is 2025-01-01, previous_year is None because the data starts in 2024,
and next_month of May 2024 is March 2025 — the empty months in between are skipped.
class DateBasedViewTests(BookshelfTestCase):
def get(self, name, **kwargs):
return self.client.get(reverse(f"catalog:{name}", kwargs=kwargs))
def test_archive_index_lists_years_and_latest_without_future_or_hidden(self):
response = self.get("archive")
self.assertEqual([d.year for d in response.context["date_list"]], [2025, 2024])
titles = [b.title for b in response.context["latest"]]
self.assertNotIn("Future", titles)
self.assertNotIn("Hidden", titles)
self.assertEqual(titles[0], "Golf")
def test_year_month_day_week(self):
year = self.get("archive-year", year=2024)
self.assertEqual([d.month for d in year.context["date_list"]], [1, 5])
self.assertEqual(len(year.context["object_list"]), 4)
self.assertEqual(year.context["next_year"], D(2025, 1, 1))
self.assertIsNone(year.context["previous_year"])
month = self.get("archive-month", year=2024, month=5)
self.assertEqual([d.day for d in month.context["date_list"]], [20, 21])
self.assertEqual(month.context["next_month"], D(2025, 3, 1))
day = self.get("archive-day", year=2024, month=5, day=20)
self.assertEqual([b.title for b in day.context["object_list"]], ["Charlie"])
week = self.get("archive-week", year=2024, week=2) # ISO week 2 of 2024: Jan 8-14
self.assertEqual(week.context["week"], D(2024, 1, 8))
self.assertEqual({b.title for b in week.context["object_list"]}, {"Alpha", "Bravo"})
def test_empty_period_is_404_because_allow_empty_is_false(self):
self.assertEqual(self.get("archive-year", year=2020).status_code, 404)
self.assertEqual(self.get("archive-month", year=2024, month=2).status_code, 404)
def test_invalid_date_is_404(self):
self.assertEqual(self.get("archive-day", year=2024, month=2, day=31).status_code, 404)
def test_future_is_hidden_unless_allow_future(self):
future = Book.objects.get(slug="future").published
resp = self.get("archive-day", year=future.year, month=future.month, day=future.day)
self.assertEqual(resp.status_code, 404)
detail = self.get(
"archive-detail", year=future.year, month=future.month, day=future.day, slug="future")
self.assertEqual(detail.status_code, 404)
def test_date_detail_matches_date_and_slug(self):
ok = self.get("archive-detail", year=2024, month=1, day=10, slug="alpha")
self.assertEqual(ok.context["object"], self.books["Alpha"])
self.assertTemplateUsed(ok, "catalog/book_detail.html")
wrong_day = self.get("archive-detail", year=2024, month=1, day=11, slug="alpha")
self.assertEqual(wrong_day.status_code, 404)
def test_date_list_period_changes_the_granularity(self):
from django.views.generic import ArchiveIndexView
from .views import PUBLISHED
view = ArchiveIndexView.as_view(
queryset=PUBLISHED, date_field="published", date_list_period="month",
template_name="catalog/book_archive.html")
response = view(RequestFactory().get("/"))
self.assertEqual(
[(d.year, d.month) for d in response.context_data["date_list"]],
[(2025, 12), (2025, 3), (2024, 5), (2024, 1)])
def test_iso_week_format_needs_the_iso_year_format(self):
from django.views.generic import WeekArchiveView
from .views import PUBLISHED
view = WeekArchiveView.as_view(
queryset=PUBLISHED, date_field="published", week_format="%V",
template_name="catalog/book_archive_week.html") # year_format left at "%Y"
with self.assertRaisesMessage(ValueError, "Use the ISO year '%G' instead"):
view(RequestFactory().get("/"), year=2024, week=2)
def test_today_view_template_and_content(self):
today = datetime.date.today()
book = Book.objects.create(
title="Today", slug="today", isbn="9780000007777", author=self.ada,
price="1.00", published=today)
response = self.client.get(reverse("catalog:archive-today"))
self.assertEqual([b.title for b in response.context["object_list"]], ["Today"])
self.assertTemplateUsed(response, "catalog/book_archive_day.html")
Source: Generic date views and Testing tools
Options and rules to know
| Attribute | Effect | Default |
|---|---|---|
|
Field used to filter and group. Required (or override |
none |
|
Include objects dated after now. Without it, a future-dated book is invisible in the archive and its |
|
|
When |
|
|
|
|
|
|
|
|
|
|
|
Granularity of |
|
|
As for |
as |
Pitfalls that cost people an afternoon:
-
The default month format is
%b, the abbreviated month name (aug, asstrptime()reads it), so a URL/2024/05/is a 404 until you setmonth_format = "%m". The URLconf then captures it with<int:month>or<str:month>. Never use the format characters of thenowtemplate tag here; these attributes usestrftime()characters. -
ISO weeks need the ISO year.
week_format = "%V"is supported (since Django 3.2), but a%Vweek is only meaningful with the ISO year%G: the defaultyear_format = "%Y"makes the view raiseValueError: ISO week directive '%V' is incompatible with the year directive '%Y'. Use the ISO year '%G' instead.— an unhandled error, a 500 page. That is whyBookWeekArchiveViewsets both;test_iso_week_format_needs_the_iso_year_formatasserts the error andtest_year_month_day_weekthe working configuration. Week numbers printed with thedatefilter’sWcharacter are not always equal tostrftime()’s `%W, so do not build URLs from them (the manual says the same). -
Time zones. If
date_fieldis aDateTimeFieldandUSE_TZis on, "the day" means the day in the current time zone; with per-user time zones the same URL can show different objects for different users. Use aDateField(asBook.publishedis) when the date is a calendar date, not an instant. -
Each
next_andprevious_*value costs a query* whenallow_empty = False, because Django looks for the next period that has objects. -
The archive views fetch everything for the period.
MonthArchiveViewhas no pagination by default; addpaginate_byfor a busy period. -
The order of
date_listis fixed (years newest first on the index, months and days oldest first); iterate it with{% for d in date_list reversed %}for the other direction.
The views are built from mixins — YearMixin, MonthMixin, DayMixin, WeekMixin, DateMixin and BaseDateListView — documented in
Mixin reference. Their get_year(), get_month() and get_day() methods try, in order, the year/month/day attribute, the URL
argument of the same name and the query-string parameter, and raise Http404 if none is usable. A route without a
year argument therefore still works with ?year=2024 in the query string, and an invalid date such as 2024-02-31 is a 404, not a
500 (the test_invalid_date_is_404 test).
Source: Date-based mixins, Django 3.2 release notes and Time zones
Mixin reference
Every generic view is a thin class that combines the mixins below, which is why this is the section to bookmark. The tables list each attribute and method with its default, taken from the 6.1 reference. Where one is not obvious, the text after the table says what overriding it is good for.
Simple mixins: ContextMixin and TemplateResponseMixin
| Member | Purpose and default |
|---|---|
|
A dict added to the context; handy in |
|
Returns the context dict; adds |
|
Template to render. Not defining one (and not overriding |
|
|
|
The class |
|
Content type of the response; |
|
Builds the response; extra keyword arguments go to the response class. |
|
List of template names to try; the first that exists wins. Default: |
Source: Simple mixins
Single-object mixins: SingleObjectMixin and SingleObjectTemplateResponseMixin
Used by DetailView, UpdateView, DeleteView and DateDetailView.
| Member | Purpose and default |
|---|---|
|
The model; |
|
A QuerySet of candidate objects; supersedes |
|
Field holding the slug ( |
|
URL keyword argument carrying the primary key ( |
|
When |
|
Context variable for the object; default is the lower-cased model name. |
|
Looks up by |
|
The |
|
Returns |
|
Requires |
|
Name of a field on the object whose value is a template name; used before the default if the field value is not |
|
|
|
|
If a view uses SingleObjectMixin outside the built-in views, set self.object yourself; get_context_data() depends on it.
Source: Single object mixins
Multiple-object mixins: MultipleObjectMixin and MultipleObjectTemplateResponseMixin
Used by ListView and all the archive views except DateDetailView.
| Member | Purpose and default |
|---|---|
|
As above. |
|
|
|
|
|
Page size; |
|
How many leftover objects the last page may absorb; default |
|
Name of the page parameter (URL keyword or query string); default |
|
The paginator; default |
|
Returns the 4-tuple |
|
Context name for the list; default |
|
Adds |
|
|
|
|
BookJsonListView uses this mixin with no template at all — plain View plus MultipleObjectMixin — to serve a paginated JSON list,
which is the cheapest way to see that the mixins are a library and not just plumbing:
class BookJsonListView(MultipleObjectMixin, View):
"""Reuse the list mixin without any template: a paginated JSON list."""
queryset = PUBLISHED
paginate_by = 3
def get(self, request, *args, **kwargs):
self.object_list = self.get_queryset()
paginator, page, books, _ = self.paginate_queryset(
self.object_list, self.get_paginate_by(self.object_list)
)
return JsonResponse(
{
"page": page.number,
"pages": paginator.num_pages,
"results": [{"title": b.title, "author": b.author.name} for b in books],
}
)
The test in PitfallTests fetches page 2 (three results, pages equal to 3) and checks that page 9 is a 404, the behaviour inherited from
paginate_queryset().
Source: Multiple object mixins
Editing mixins: FormMixin, ModelFormMixin, ProcessFormView and DeletionMixin
| Member | Purpose and default |
|---|---|
|
The form class to instantiate. |
|
Initial data; |
|
Prefix for the form’s field names. |
|
Redirect target after a valid form. |
|
Instantiates the form with |
|
|
|
Redirect to |
|
Adds |
|
List of field names, interpreted like |
|
The |
|
Adds |
|
Accepts |
|
Saves the form, sets |
|
Render the form / validate and dispatch to |
|
Redirect after deletion; accepts |
|
Deletes |
Source: Editing mixins
Date-based mixins
| Member | Purpose and default |
|---|---|
|
|
|
Same for months; default format |
|
Same for days; default format |
|
Same for weeks; default |
|
The |
|
Whether objects dated in the future appear; default |
|
Default |
|
|
|
Returns |
|
Filters the queryset with |
|
Dates of type |
The test for date_list_period shows the effect: with "month" the index lists (2025, 12), (2025, 3), (2024, 5) and (2024, 1) instead of the years 2025 and 2024.
Source: Date-based mixins
Mixins from other apps that belong in this family
-
django.contrib.auth.mixins.LoginRequiredMixin,PermissionRequiredMixin,UserPassesTestMixinandAccessMixin, described under Scoping querysets to request.user. -
django.contrib.messages.views.SuccessMessageMixin: thesuccess_messageattribute andget_success_message(cleaned_data)method; used with every editing view on this page.
Source: Class-based views mixins and Flattened index
Sync and async behaviour
The generic views are written as ordinary synchronous Python, and that is the right default: the ORM calls in get_queryset(), get_object(), form_valid() and the
paginator are synchronous, and so are the form and template layers. What that means in practice depends on the server:
-
Under WSGI (Gunicorn’s default workers,
runserverwithout ASGI) everything is sync and nothing special happens. -
Under ASGI (Uvicorn, Daphne, Granian;
runserverwith Daphne), Django runs a sync view in a worker thread (sync_to_async()with thread-sensitive mode) so it does not block the event loop. A generic view therefore works unchanged, with a small thread-hop cost per request. The checkBookListView.view_is_asyncisFalse; for an async handler it isTrue. -
Template rendering is always sync work, even for an async view:
render_to_response()returns a lazyTemplateResponse, and the handler renders it in a thread afterwards. That is why the async list view below can still use a template that touches the ORM — but a template that reads a not-yet-evaluated QuerySet does that query synchronously.
An async generic view is a generic view whose handler is async def. Since the view base class (View) supports async handlers (Django 3.1), you override get() in a
display view and use the async ORM (async for, aget(), acount(); Django 4.1) and the async shortcuts (aget_object_or_404() and aget_list_or_404(), Django 5.0):
class AsyncBookListView(ListView):
queryset = PUBLISHED
context_object_name = "books"
template_name = "catalog/book_list.html"
async def get(self, request, *args, **kwargs):
# Evaluate the queryset asynchronously; template rendering is done later by the
# handler (in a worker thread), so the template can still touch the ORM.
self.object_list = [book async for book in self.get_queryset()]
return self.render_to_response(self.get_context_data())
class AsyncBookDetailView(DetailView):
queryset = PUBLISHED
async def get(self, request, *args, **kwargs):
self.object = await aget_object_or_404(self.get_queryset(), slug=kwargs["slug"])
return self.render_to_response(self.get_context_data(object=self.object))
Source: Asynchronous support and Base views
The rules, each backed by something that ran:
-
All handlers of one class must be sync or all async.
View.view_is_asyncinspects the handlers.ListViewandDetailViewonly defineget()(plusoptions(), which adapts), so overridingget()asasync defis allowed.CreateView,UpdateView,FormViewandDeleteViewdefinepost(),put()ordelete()as sync methods, so an asyncget()on them fails whenas_view()is called:ImproperlyConfigured: Mixed HTTP handlers must either be all sync or all async.(the last test ofAsyncViewTestsasserts it). To make an editing view async you would override every handler, which gives up almost everything the generic view does; for editing, keep the sync view. -
Do not touch the ORM synchronously in an async handler. Evaluating a QuerySet (
list(qs), iteration,get_object(),paginate_queryset()) insideasync defraisesSynchronousOnlyOperation.AsyncBookListViewevaluates the queryset withasync forand setsself.object_listto a list, andget_context_data()then does no queries.ListViewpagination is sync-only; Django 6.0 addedAsyncPaginatorandAsyncPage, but the generic views do not use them, so an async list that paginates has to call the async paginator itself. The script below shows the failure forpaginate_queryset()and forget_object():
class PaginatingAsyncList(ListView):
model = Book
paginate_by = 3
async def get(self, request, *args, **kwargs):
self.object_list = self.get_queryset()
self.paginate_queryset(self.object_list, 3) # runs COUNT(*) synchronously
return None
class SyncLookupAsyncDetail(DetailView):
model = Book
async def get(self, request, *args, **kwargs):
self.get_object() # runs a SELECT synchronously
return None
python manage.py shell < ex/async_sync_orm.pyPaginatingAsyncList: SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
SyncLookupAsyncDetail: SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
Source: Async safety
-
The mixins from
django.contrib.auth.mixinsare sync-only. They overridedispatch()as a sync method and readrequest.user, which loads the session from the database. In front of an async handler the next script fails in both outcomes — the redirect for an anonymous user is returned where an awaitable is expected, and a logged-in user triggers a synchronous database read in the event loop:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import path
from catalog.views import AsyncBookListView
class ProtectedAsyncList(LoginRequiredMixin, AsyncBookListView):
pass
urlpatterns = [path("protected/", ProtectedAsyncList.as_view())]
import asyncio
from django.contrib.auth import get_user_model
from django.test import AsyncClient, override_settings
async def attempt(label, client):
try:
response = await client.get("/protected/")
print(f"{label}: HTTP {response.status_code}")
except Exception as exc:
print(f"{label}: {type(exc).__name__}: {exc}")
async def main():
client = AsyncClient(raise_request_exception=True)
with override_settings(ROOT_URLCONF="ex.urls_async", ALLOWED_HOSTS=["*"]):
await attempt("anonymous", client)
await client.aforce_login(await get_user_model().objects.aget(username="alice"))
await attempt("logged in", client)
asyncio.run(main())
anonymous: TypeError: object HttpResponseRedirect can't be used in 'await' expression
logged in: SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
Source: LoginRequiredMixin
Use await request.auser() (Django 5.0) in an async def dispatch(), as the mixin below does; its test logs in with aforce_login() (also 5.0) and checks both the redirect and the 200. Django 5.1’s LoginRequiredMiddleware is a
site-wide alternative that works for sync and async views alike.
class AsyncLoginRequiredMixin:
"""LoginRequiredMixin is sync-only; this is the async-handler equivalent."""
async def dispatch(self, request, *args, **kwargs):
user = await request.auser() # request.user would hit the database synchronously
if not user.is_authenticated:
return redirect_to_login(request.get_full_path())
return await super().dispatch(request, *args, **kwargs)
class ProtectedAsyncBookListView(AsyncLoginRequiredMixin, AsyncBookListView):
pass
class AsyncViewTests(BookshelfTestCase):
async def test_async_list_and_detail(self):
response = await self.async_client.get(reverse("catalog:async-book-list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Alpha")
detail = await self.async_client.get(reverse("catalog:async-book-detail", kwargs={"slug": "alpha"}))
self.assertContains(detail, "Average rating")
missing = await self.async_client.get(reverse("catalog:async-book-detail", kwargs={"slug": "nope"}))
self.assertEqual(missing.status_code, 404)
async def test_async_login_check_with_auser(self):
url = reverse("catalog:async-protected")
anonymous = await self.async_client.get(url)
self.assertEqual(anonymous.status_code, 302)
self.assertEqual(anonymous["Location"], "/accounts/login/?next=/catalog/async/protected/")
await self.async_client.aforce_login(self.alice)
self.assertEqual((await self.async_client.get(url)).status_code, 200)
def test_generic_views_are_sync_and_mixing_sync_and_async_handlers_fails(self):
from django.core.exceptions import ImproperlyConfigured
from django.views.generic import CreateView, ListView
from .views import AsyncBookListView, BookListView
self.assertFalse(BookListView.view_is_async)
self.assertTrue(AsyncBookListView.view_is_async)
class Mixed(CreateView):
model = Author
fields = ["name"]
async def get(self, request, *args, **kwargs): # post() stays sync
...
with self.assertRaises(ImproperlyConfigured):
Mixed.as_view()
Source: Async views, Authentication in web requests and Testing tools
Which to choose? Keep the generic views sync, and reach for an async handler only when the page waits on slow I/O that can run concurrently — several outbound HTTP calls in
one get(), say. A sync generic view serving a database page is not slower for being sync; what an async version of the same view changes is the thread it runs on, not the query.
Running the examples
All the code above is one app. Because it is a complete app, it can be exercised three ways.
Checks and the test suite. manage.py check finds configuration mistakes before a request does (a missing fields, a bad URL name), and the 38 tests assert behaviour: status
codes, templates, context, database side effects, who may see what.
python manage.py check && python manage.py test catalog -v 2 (test names only)Found 38 test(s).
test_async_login_check_with_auser ... ok
test_generic_views_are_sync_and_mixing_sync_and_async_handlers_fails ... ok
test_archive_index_lists_years_and_latest_without_future_or_hidden ... ok
test_date_detail_matches_date_and_slug ... ok
test_date_list_period_changes_the_granularity ... ok
test_empty_period_is_404_because_allow_empty_is_false ... ok
test_future_is_hidden_unless_allow_future ... ok
test_invalid_date_is_404 ... ok
test_iso_week_format_needs_the_iso_year_format ... ok
test_today_view_template_and_content ... ok
test_year_month_day_week ... ok
test_detail_404_for_unpublished ... ok
test_detail_by_slug_counts_views ... ok
test_dynamic_filtering_from_url ... ok
test_list_is_paginated_and_hides_unpublished ... ok
test_page_last_and_bad_page ... ok
test_search_filters_by_title_or_author ... ok
test_anonymous_is_redirected_to_login ... ok
test_create_review_sets_reviewer_and_book ... ok
test_duplicate_review_is_a_form_error_not_a_500 ... ok
test_json_negotiation ... ok
test_permission_required_distinguishes_anonymous_and_denied ... ok
test_posted_reviewer_field_is_ignored ... ok
test_rating_range_comes_from_the_model ... ok
test_invalid_post_redisplays_form_with_errors ... ok
test_login_required_then_valid_post_sends_mail_and_redirects ... ok
test_anonymous_never_reaches_get_queryset ... ok
test_delete_needs_the_confirmation_checkbox ... ok
test_list_shows_only_my_reviews ... ok
test_other_users_review_is_404_for_update_and_delete ... ok
test_update_own_review ... ok
test_head_and_options ... ok
test_http_delete_skips_the_confirmation_form_but_not_the_scoping ... ok
test_list_mixin_reused_without_templates ... ok
test_per_page_is_clamped ... ok
test_restricting_http_method_names_rejects_delete ... ok
test_paginating_an_unordered_queryset_warns ... ok
Ran 38 tests in 1.970s
OK
System check identified no issues (0 silenced).
A real server. The same pages, fetched with curl against python manage.py runserver 8124, on the seeded database:
B=http://127.0.0.1:8124/catalog
curl -s "$B/" | grep '<li>' | sed 's/^ *//'
curl -s -o /dev/null -w "page=99 %{http_code}\n" "$B/?page=99"
curl -s "$B/books/alpha/" | grep "<h1>\|Average"
curl -s -o /dev/null -w "unknown book %{http_code}\n" "$B/books/nope/"
curl -s "$B/archive/2024/" | grep '<h1>\|<li>'
curl -s -o /dev/null -w "archive/2020 %{http_code}\n" "$B/archive/2020/"
curl -s "$B/archive/2024/week/2/" | grep '<h1>\|<li>'
curl -s -D - -o /dev/null -X OPTIONS "$B/" | grep -i '^allow'
curl -s -D - -o /dev/null "$B/my-reviews/" | grep -i '^location'
curl -s "$B/books.json?page=2"
<li><a href="/catalog/books/golf/">Golf</a> by Ada Writer (2025-12-31)</li>
<li><a href="/catalog/books/foxtrot/">Foxtrot</a> by Bo Novelist (2025-03-04)</li>
<li><a href="/catalog/books/echo/">Echo</a> by Ada Writer (2025-03-03)</li>
page=99 404
<h1>Alpha</h1>
<p>Average rating: 5.0</p>
unknown book 404
<h1>Books of 2024</h1>
<ul><li><a href="/catalog/archive/2024/1/">January</a></li><li><a href="/catalog/archive/2024/5/">May</a></li></ul>
<ul><li>2024-05-21: Delta</li><li>2024-05-20: Charlie</li><li>2024-01-11: Bravo</li><li>2024-01-10: Alpha</li></ul>
archive/2020 404
<h1>Week starting 2024-01-08</h1>
<ul><li>2024-01-11: Bravo</li><li>2024-01-10: Alpha</li></ul>
Allow: GET, HEAD, OPTIONS
Location: /accounts/login/?next=/catalog/my-reviews/
{"page": 2, "pages": 3, "results": [{"title": "Delta", "author": "Bo Novelist"}, {"title": "Charlie", "author": "Bo Novelist"}, {"title": "Bravo", "author": "Ada Writer"}]}
The my-reviews page redirects an anonymous visitor to the login URL with ?next= set, the 404s are real 404s, and the OPTIONS response lists exactly the methods
the view implements.
The shell scripts that printed the hook order, the MRO, the misconfiguration messages and the option behaviours are ex/trace.py, ex/mro.py, ex/misconfig.py and ex/options.py, run with
python manage.py shell < ex/<name>.py. They need a seeded database; the seed is a handful of create() calls and is not shown.
Source: Writing and running tests and django-admin check
The test class that proves the pagination warning for an unordered queryset:
class UnorderedWarningTests(BookshelfTestCase):
def test_paginating_an_unordered_queryset_warns(self):
from django.views.generic import ListView
class AuthorList(ListView):
model = Author
paginate_by = 1
template_name = "catalog/author_books.html"
request = self.client.get("/").wsgi_request
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
view = AuthorList()
view.setup(request)
view.object_list = view.get_queryset()
view.get_context_data()
self.assertTrue(any(issubclass(w.category, UnorderedObjectListWarning) for w in caught))
Pitfalls and the error messages they produce
Most generic-view problems are a misconfiguration that Django reports with a specific message. The script below triggers each of them on purpose, so that you can recognise the message later:
attempt("CreateView without fields", CreateView.as_view(model=Author), rf.get("/"))
attempt("fields and form_class", CreateView.as_view(model=Author, fields=["name"], form_class=ReviewForm), rf.get("/"))
attempt("FormView without success_url",
FormView.as_view(form_class=SuggestionForm, template_name="catalog/suggestion_form.html"),
rf.post("/", {"title": "Dune", "author_name": "F. Herbert"}))
attempt("ListView without model, queryset or get_queryset", ListView.as_view(), rf.get("/"))
attempt("DetailView without pk or slug in the URL", DetailView.as_view(model=Book), rf.get("/"))
attempt("FormView without template_name", FormView.as_view(form_class=SuggestionForm), rf.get("/"))
attempt("ListView allow_empty=False on an empty queryset",
ListView.as_view(queryset=Book.objects.none(), allow_empty=False, template_name="catalog/book_list.html"), rf.get("/"))
print("template names:")
print(" ListView ", ListView(model=Book, object_list=Book.objects.all()).get_template_names())
print(" CreateView ", CreateView(model=Book, object=None).get_template_names())
print(" DeleteView ", DeleteView(model=Book, object=Book()).get_template_names())
print(" UpdateView suffix override:", UpdateView(model=Book, object=Book(), template_name_suffix="_edit").get_template_names())
python manage.py shell < ex/misconfig.pyCreateView without fields: ImproperlyConfigured: Using ModelFormMixin (base class of CreateView) without the 'fields' attribute is prohibited.
fields and form_class: ImproperlyConfigured: Specifying both 'fields' and 'form_class' is not permitted.
FormView without success_url: ImproperlyConfigured: No URL to redirect to. Provide a success_url.
ListView without model, queryset or get_queryset: ImproperlyConfigured: ListView is missing a QuerySet. Define ListView.model, ListView.queryset, or override ListView.get_queryset().
DetailView without pk or slug in the URL: AttributeError: Generic detail view DetailView must be called with either an object pk or a slug in the URLconf.
FormView without template_name: ImproperlyConfigured: TemplateResponseMixin requires either a definition of 'template_name' or an implementation of 'get_template_names()'
ListView allow_empty=False on an empty queryset: Http404: Empty list and “ListView.allow_empty” is False.
template names:
ListView ['catalog/book_list.html']
CreateView ['catalog/book_form.html']
DeleteView ['catalog/book_confirm_delete.html']
UpdateView suffix override: ['catalog/book_edit.html']
Source: Model forms and Editing mixins
Note that the DetailView without a pk or slug raises an AttributeError, not an ImproperlyConfigured, and so does not show up in
manage.py check; the first request to that URL is where you find out. Every other row is raised when the view is first used. The tests in this page are what
catch them in CI: one request per view is enough.
The rest of the checklist, from the earlier sections:
| Pitfall | Remedy |
|---|---|
Mutating the class-level |
Use |
|
It cannot: the view instance is per request. Class attributes are shared, so keep mutable defaults (lists, dicts, QuerySets) out of them or copy them. |
Overriding |
The |
Filtering in the template or in |
The template filter still serves hidden rows to anyone with the URL, and pagination counts the wrong total. Filter the queryset. |
|
A new model column becomes browser-writable. List |
Setting |
By then the object is saved without it (an |
Returning nothing from |
The view must return an |
|
Fails at import (the URLconf is not loaded yet). |
Placeholders in |
Only attributes of the saved object are available ( |
|
It already does: a |
Custom deletion logic in a |
Since Django 4.0 the |
|
Put access mixins first; anything touching |
Using |
Combine the tests into one |
|
Set |
|
|
An async |
|
|
|
Using |
It is true for |
The rule behind the table: the generic views make the common path short, and every pitfall is the generic view doing exactly what it is documented to do when it is configured with defaults that do not fit. Read the "Method Flowchart" of the view you extend, in the reference, before you override a hook.
Choosing between generic views, function views and your own class
Use a generic view when the page is one of the shapes in the first table and the differences from the default are a queryset, a template, a form class
or a couple of hooks. Use a function-based view (see Function-Based Views & Shortcuts) when the logic is a short sequence that does not fit the shape — an endpoint that calls three services, a webhook receiver — or when reading the generic view’s hooks would take longer than writing twenty lines. Use a plain
View subclass, or View plus a mixin from this page (as BookJsonListView does), when you want the object lifecycle (setup(), dispatch(), method
routing) but not templates or forms.
Practical rules that keep generic views maintainable:
-
Keep the inheritance shallow: at most one generic view and a few small mixins that do one thing each, with
LoginRequiredMixinfirst and the generic view last. -
Put the rules in the places the framework already calls: query restrictions in
get_queryset(), ownership inform_valid(), extra form arguments inget_form_kwargs(), validation in the form. A view whosepost()re-implements form handling has stopped being a generic view. -
Keep business logic out of the view: call a model method, a manager method or a service function from
form_valid()so that the same code can serve an admin action, a management command or a Celery task. -
Write one test per view that checks the status, the template or redirect, the context or the database effect, and the denial cases.
-
Django REST Framework has its own
genericsmodule (ListAPIView,ListCreateAPIViewand so on) that follows the same idea for JSON APIs; it is a separate library and has its own page later in this section. The Django generic views here render HTML.
Where the books are outdated
The four books that informed this section were written against Django 1.11 to 2.1. For generic views the concepts survived; the details that changed are the ones that bite when you copy a listing:
-
Everything about class-based generic views dates from Django 1.3 (2011). If an old tutorial mentions the function-based generic views (
direct_to_template,object_listand others in modules such asdjango.views.generic.list_detail), those were deprecated in 1.3 and are long gone; the class-based views here are the only generic views. -
url()in the URLconf of an older listing no longer exists (removed in 4.0); writepath()orre_path()and the<int:pk>converters shown in the URLconf above. -
DeleteViewlogic. Books that customise deletion by overridingdelete()describe the pre-4.0 flow. Since 4.0,POSTgoes throughform_valid()and a form, so the logic belongs there andSuccessMessageMixinfinally works. -
Success URL placeholders in older code use
%(slug)s; the{slug}syntax arrived in 1.8 and the old one was removed in 1.10. -
fields. Old code relies on aCreateViewwithoutfields, which generated a form with every field; that is anImproperlyConfigurederror today (see the output above), andfields = "all"is a security anti-pattern, not a convenience. Specifyingfieldsandform_classtogether has been an error since 1.8. -
Permissions in views. Books that predate 1.9 wrap
dispatch()in@method_decorator(login_required); useLoginRequiredMixinandPermissionRequiredMixin(added in 1.9) leftmost in the class. A book listing that puts a check indispatch()before the mixin runs it for anonymous users first, which is a bug in the book, not a technique. -
request.user.is_authenticated()as a method call was a property from 1.10 and the call form was removed in 2.0; the mixins hide it, but hand-written checks in old code still show it. -
Tests that pass for the wrong reason. A test that POSTs a delete and only asserts
status_code == 200(a 200 means the confirmation page was shown, not that anything was deleted), or that never checks the row afterwards, proves nothing. The tests on this page re-read the database after every write. -
Template and helper names (
render_to_response,ugettext,{% load staticfiles %}) in old view code were removed in 3.0 and 4.0; userender()/TemplateResponse,gettextand{% load static %}. -
Books show
GETlinks that change state (a "delete" or "add to basket" link). Those are CSRF holes; the generic editing views usePOSTwith{% csrf_token %}.
What changed recently
| Django | Change relevant to generic views |
|---|---|
6.1 |
|
6.0 |
|
5.2 |
|
5.1 |
|
5.0 |
|
4.1 |
The async ORM ( |
4.0 |
|
3.2 |
|
3.1 |
Async handlers on |
2.2 |
|
2.0 |
|
1.9 |
The access mixins |
Django 5.2 is the current LTS (security fixes until April 2028) and 6.1 the latest release; every API on this page exists in both, except the Django 6.0+ items in the table. The generic views were designed once, in 1.3, and have been extended rather than rewritten since, which is why a ten-year-old listing is usually 90 percent right and one removed name or one changed hook away from working.