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.ordering is set, because paginating an unordered QuerySet gives unstable pages (see Pagination).

  • Book.published is a DateField, not a DateTimeField, which is what the date-based views want (see Date-based views).

  • Review belongs to a user (reviewer) and has a UniqueConstraint, which is what makes it the right model for queryset scoping to request.user (see Scoping querysets to request.user).

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

catalog/forms.py
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.

catalog/urls.py
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"),
]

The templates are ordinary Django templates; they are listed where each view needs them. All of them extend one tiny base template:

catalog/templates/catalog/base.html
<!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

ListView

A page of objects (optionally paginated)

_list

object_list, <model>_list, plus paginator, page_obj, is_paginated when paginated

GET, HEAD, OPTIONS

DetailView

One object, looked up by pk or slug

_detail

object, <model>

GET, HEAD, OPTIONS

FormView

A plain (non-model) form

none — set template_name

form

GET, POST, PUT, HEAD, OPTIONS

CreateView

A ModelForm that creates an object

_form

form, plus object and <model> once an object exists (an edit, or after saving)

GET, POST, PUT, HEAD, OPTIONS

UpdateView

A ModelForm that edits an existing object

_form

form, object, <model>

GET, POST, PUT, HEAD, OPTIONS

DeleteView

A confirmation page, then deletion

_confirm_delete

form, object, <model>

GET, POST, DELETE, HEAD, OPTIONS

ArchiveIndexView

The latest objects and the years that have objects

_archive

latest, date_list

GET, HEAD, OPTIONS

YearArchiveView

Months of one year that have objects

_archive_year

year, date_list, next_year, previous_year, optionally object_list

GET, HEAD, OPTIONS

MonthArchiveView

Objects of one month

_archive_month

month, date_list, next_month, previous_month, object_list

GET, HEAD, OPTIONS

WeekArchiveView

Objects of one week

_archive_week

week, next_week, previous_week, object_list

GET, HEAD, OPTIONS

DayArchiveView and TodayArchiveView

Objects of one day (today for the second)

_archive_day

day, next_day, previous_day, next_month, previous_month, object_list

GET, HEAD, OPTIONS

DateDetailView

One object, looked up by date and pk or slug

_detail

object, <model>

GET, HEAD, OPTIONS

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:

flowchart TD req["HTTP request
(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 to self inside 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 the queryset warning in Single-object mixins: SingleObjectMixin and SingleObjectTemplateResponseMixin).

  • setup() runs before dispatch(). Since Django 2.2 View.setup() stores request, args and kwargs on the instance (and makes head an alias of get). A mixin that needs per-request attributes can override setup() and be sure they exist in every handler. Raising Http404 there happens before an access mixin’s dispatch() check, which is why this page looks things up lazily instead (see the book property 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 with process_template_response() can still change template_name or context_data of a generic view’s response. That is also why a QuerySet used in the template is evaluated after the view returns.

  • Access mixins run in dispatch(). LoginRequiredMixin, PermissionRequiredMixin and UserPassesTestMixin override dispatch(); for an anonymous user the handler, get_queryset() and get_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:

ex/trace.py (excerpt)
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})
Output of python manage.py shell < ex/trace.py
ListView 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() calls get_queryset() first and get_context_data() second; pagination (paginate_queryset()) happens inside get_context_data(), which is why overriding get_context_data() is the right place to add data, but not to filter.

  • DetailView.get() calls get_object(), which calls get_queryset(); overriding get_queryset() therefore restricts what get_object() can find, while overriding get_object() runs once per request after the lookup.

  • On an invalid POST, CreateView calls form_invalid(), which re-renders the same template with the bound form; get_form() is not called a second time because form_invalid() passes the form into get_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:

ex/mro.py
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__)
Output
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:

catalog/views.py — BookListView
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
catalog/templates/catalog/book_list.html
{% 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 %}
catalog/templates/catalog/pagination.html
{% 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 %}

What each part does:

  • queryset = PUBLISHED fixes the starting set of rows (published books, with their author loaded in the same query by select_related(), so the template does not run one extra query per row).

  • get_queryset() is where request-dependent filtering belongs. The q search parameter narrows the class-level queryset with a Q object; because super().get_queryset() returns a clone, adding filters never mutates the shared class attribute.

  • get_context_data() adds q so the search box can redisplay what was typed. Always call super() first: the parent builds object_list, the pagination variables and view.

  • context_object_name = "books" gives template authors a meaningful name; object_list is still there too. Without it the name would be book_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:

catalog/views.py — BookDetailView
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
catalog/templates/catalog/book_detail.html
{% 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 %}

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:

catalog/views.py — AuthorBookListView
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
catalog/templates/catalog/author_books.html
{% 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 %}

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

model, queryset, get_queryset()

Which rows. queryset wins over model; override get_queryset() for anything depending on the request.

none — one of them is required

ordering, get_ordering()

order_by() arguments applied to the queryset (a string or a list).

the model’s Meta.ordering

context_object_name, get_context_object_name()

The extra context variable holding the list (or the object).

<model>_list / <model>

template_name, template_name_suffix, get_template_names()

Which template(s) to try, in order.

<app>/<model><suffix>.html

template_name_field (detail views)

Name of a field on the object that holds a template name, tried before the default.

None

allow_empty (list views)

When False, an empty list is a 404 instead of an empty page.

True

pk_url_kwarg, slug_url_kwarg, slug_field, query_pk_and_slug (detail views)

How the object is looked up (see Single-object mixins: SingleObjectMixin and SingleObjectTemplateResponseMixin).

"pk", "slug", "slug", False

paginate_by, paginate_orphans, page_kwarg, paginator_class

Pagination (next section).

no pagination, 0, "page", Paginator

extra_context

A dict merged into the context; convenient in as_view() calls.

None

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.

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:

Listing 1 — pagination options (ex/options.py, first part)
# 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"])
Output of 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

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: paginating Author.objects.all() (no Meta.ordering) printed UnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list: <class 'catalog.models.Author'> QuerySet. The Book model has a Meta.ordering, so BookListView does not warn; UnorderedWarningTests in 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=100000 cannot make one request load the whole table.

  • paginate_by counts objects, not queries. A page of 3 books with the template reading book.author.name would still run a query per row without the select_related("author") in PUBLISHED; see Query Optimization & Fetch Modes.

catalog/views.py — PerPageMixin
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.

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":

catalog/tests.py — DisplayViewTests and the shared fixture
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:

flowchart TD get["GET"] --> getform["get_context_data()
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.

catalog/views.py — SuggestBookView
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
catalog/templates/catalog/suggestion_form.html
{% extends "catalog/base.html" %}
{% block content %}
<h1>Suggest a book</h1>
<form method="post">{% csrf_token %}
  {{ form.as_p }}
  <button>Send</button>
</form>
{% endblock %}

Source: FormView and FormMixin

  • success_url uses reverse_lazy() because the attribute is evaluated when the module is imported, before the URLconf is loaded; reverse() there would fail. Alternatively override get_success_url(), which runs per request and can use reverse().

  • get_initial() pre-fills the form from the query string (/catalog/suggest/?title=Dune); it returns a copy of the initial dict, 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 returns super().form_valid(form), which issues the redirect. Redirecting after a successful POST (the "post/redirect/get" pattern) means a browser refresh does not re-send the form.

  • {% csrf_token %} is in the template. The default CsrfViewMiddleware rejects a POST without it.

  • The thanks page is a TemplateView configured in the URLconf with template_name, as no logic is needed.

  • In the email, from_email=None means DEFAULT_FROM_EMAIL. Which mail backend delivers it, and the Django 6.1 MAILERS setting, 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.

catalog/tests.py — FormViewTests
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)

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"]
  • fields is required when Django generates the form. A model form view without fields (and without form_class) raises ImproperlyConfigured — the same as writing a ModelForm without Meta.fields. List the fields explicitly; never use fields = "all", because the day someone adds an is_staff-like column to the model it becomes writable from the browser (mass assignment).

  • fields together with form_class is an error (ImproperlyConfigured) since Django 1.8; before that fields was silently ignored, which is how stale tutorials ended up with forms that did not match their fields list.

  • form_class still needs model: the documentation says to specify it even when the form_class is a ModelForm that already knows its model.

  • The redirect: with no success_url, ModelFormMixin.get_success_url() calls get_absolute_url() on the saved object (Book.get_absolute_url() here), and raises ImproperlyConfigured if neither exists. A success_url string may contain {field} placeholders that are filled with str.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)s form 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; set template_name_suffix (for example "_update_form") or template_name for a separate template.

  • self.object is None while a CreateView shows its form and is set to the saved instance in form_valid(); an UpdateView sets it in get() and post() before building the form.

The script that checked the redirect rules:

ex/options.py (second part)
# 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()
Output
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/

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:

catalog/views.py — ReviewCreateView and JsonableResponseMixin
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
catalog/templates/catalog/review_form.html
{% 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. LoginRequiredMixin must be leftmost so its dispatch() runs first; JsonableResponseMixin and SuccessMessageMixin wrap form_valid()/form_invalid() and so must precede CreateView, whose versions they extend with super().

  • book is a cached_property, not a lookup in setup(). It runs on first use — in get_form_kwargs() or form_valid() — which is after the login check, and only once per request even though several hooks read it. A Http404 for an unknown slug therefore cannot leak whether a book exists to anonymous visitors, and no query runs for them at all.

  • get_form_kwargs() hands book and reviewer to ReviewForm, whose clean() turns a second review of the same book into an ordinary non-field error instead of an IntegrityError and a 500. (The UniqueConstraint is the backstop. A constraint on fields that are not on the form is not checked by ModelForm validation, because validate_unique() and validate_constraints() skip excluded fields; that is why the check is in clean().) Passing request.user to the form this way, rather than reaching into request from the form, keeps the form testable.

  • form_valid() assigns form.instance.book and form.instance.reviewer, so a hostile browser cannot choose them: even a posted reviewer=2 is ignored because it is not in fields (the test_posted_reviewer_field_is_ignored test below posts it).

  • form_valid() assigns to form.instance and then calls super().form_valid(form), which saves once and redirects. Do not call form.save() yourself and then super().form_valid(form): the base class saves a second time (an extra UPDATE and a second round of save signals). If you want full control, use self.object = form.save(commit=False), set the fields, call self.object.save() and form.save_m2m() (needed for many-to-many fields when commit=False), and return your own redirect without calling super().form_valid().

  • SuccessMessageMixin queues a flash message after a valid form. Its success_message is formatted with % and the form’s cleaned_data, so a literal percent sign in the message would raise; for text that uses the saved object, override get_success_message(), as above.

  • get_context_data() adds book so 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.

catalog/tests.py — EditingViewTests
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)

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.

catalog/templates/catalog/review_confirm_delete.html
{% 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 %}

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.

catalog/views.py — scoped list, update and delete views
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."

How it works:

  • OwnReviewsMixin extends LoginRequiredMixin, so a single base class gives both rules, and its get_queryset() calls super().get_queryset() before adding .filter(reviewer=self.request.user). Because the generic view’s own get_queryset() is next in the MRO (the mixin is listed first), the model or queryset attribute still applies.

  • get_object() runs get_queryset().get(pk=…​); for another user’s review that is DoesNotExist, which the generic view turns into Http404. 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, so self.request.user is a real user inside every handler and the filter never receives an AnonymousUser. That only holds when the access mixin is the leftmost base class.

  • ReviewUpdateView uses the same ReviewForm as the create view and passes the book of the existing review through get_form_kwargs(). self.object is already loaded when get_form_kwargs() runs for an UpdateView.

  • ReviewDeleteView combines the scoping mixin with SuccessMessageMixin and a confirmation form; its success_url uses reverse_lazy() for the same import-time reason as before.

catalog/templates/catalog/review_list.html
{% 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 %}
catalog/tests.py — OwnerScopingTests
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.

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

LoginRequiredMixin

Anonymous users are redirected to the login page (or get a 403 when raise_exception = True). It does not check is_active; the default authentication backends already reject inactive users. It must be the leftmost base class.

PermissionRequiredMixin

Set permission_required to one "app_label.codename" string or an iterable (all are required). Override get_permission_required() or has_permission() for custom logic. Anonymous users are redirected to login; authenticated users without the permission get a 403.

UserPassesTestMixin

Override test_func() (or get_test_func()) to return a boolean. It cannot be stacked: two UserPassesTestMixin subclasses in one MRO do not combine, because the first test_func() does not call the second.

AccessMixin

The base: login_url (get_login_url(), falls back to settings.LOGIN_URL), redirect_field_name ("next"; None omits it), raise_exception (default False), permission_denied_message and handle_no_permission(), which you can override to change how denial is answered.

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’s dispatch(); 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 whose dispatch() reads request.user.profile, to the left of LoginRequiredMixin means your code runs for anonymous users first and crashes with an AttributeError on AnonymousUser. Any extra mixin that uses request.user must come after LoginRequiredMixin.

  • Filtering after the fact. get_object() then if obj.reviewer != request.user: raise PermissionDenied is correct but only for that one handler, and leaks existence with a 403. Filter the queryset.

  • DELETE skips the form. DeletionMixin.delete() is still a handler of DeleteView: an HTTP DELETE request deletes the object without validating ConfirmDeleteForm (and without the success message), because only the POST handler runs the form. The scoping still holds, since delete() also calls get_object() (the test_http_delete_skips_the_confirmation_form_but_not_the_scoping test pins both facts down). If the confirmation form is a real business rule, not just UX, restrict http_method_names to ["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 = True makes get_object() match both the primary key and the slug (the script above shows a mismatched slug giving a 404), and a non-sequential slug or UUID key hides the rest. This is a mitigation, not authorization: scoping the queryset is.

  • CSRF. All the state-changing views use POST and the templates contain {% csrf_token %}. A "delete" link that is a plain <a href> to a URL that deletes on GET is a CSRF hole; DeleteView does not delete on GET.

catalog/tests.py — PitfallTests
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.

catalog/views.py — the seven date-based views
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:

catalog/templates/catalog/book_archive.html
{% 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 %}
catalog/templates/catalog/book_archive_year.html
{% 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 %}
catalog/templates/catalog/book_archive_month.html
{% 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 %}
catalog/templates/catalog/book_archive_week.html
{% 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 %}
catalog/templates/catalog/book_archive_day.html
{% 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).

What each view provides

View date_list holds Other context

ArchiveIndexView

every year with objects (newest first); date_list_period changes it to "month" or "day"

latest (the objects, newest first; paginate with paginate_by)

YearArchiveView

every month of the year with objects (oldest first)

year, next_year, previous_year; object_list holds the year’s objects only if make_object_list = True, otherwise it is an empty queryset

MonthArchiveView

every day of the month with objects

month, next_month, previous_month, object_list

WeekArchiveView

not provided

week (a date: the first day of the week), next_week, previous_week, object_list

DayArchiveView

not provided

day, next_day, previous_day, next_month, previous_month, object_list

TodayArchiveView

not provided

as DayArchiveView, using today’s date instead of URL arguments

DateDetailView

not provided

object (and <model>); the URL must match both the date and the pk/slug

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.

catalog/tests.py — DateBasedViewTests
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")

Options and rules to know

Attribute Effect Default

date_field

Field used to filter and group. Required (or override get_date_field()).

none

allow_future

Include objects dated after now. Without it, a future-dated book is invisible in the archive and its DateDetailView URL is a 404; a day in the future is a 404 even when objects exist for it.

False

allow_empty

When False, a period with no objects is a 404; when True an empty page is shown. Different from the list views: ListView.allow_empty is True, the date views' is False.

False

year_format, month_format, day_format

strftime() formats used to parse the URL arguments.

"%Y", "%b", "%d"

week_format

"%U" (weeks start on Sunday), "%W" (Monday), or "%V" (ISO 8601).

"%U"

make_object_list

YearArchiveView only: also provide the year’s objects.

False

date_list_period

Granularity of date_list for ArchiveIndexView and its subclasses.

"year"

paginate_by, ordering, context_object_name

As for ListView, because the archive views include MultipleObjectMixin.

as ListView

Pitfalls that cost people an afternoon:

  • The default month format is %b, the abbreviated month name (aug, as strptime() reads it), so a URL /2024/05/ is a 404 until you set month_format = "%m". The URLconf then captures it with <int:month> or <str:month>. Never use the format characters of the now template tag here; these attributes use strftime() characters.

  • ISO weeks need the ISO year. week_format = "%V" is supported (since Django 3.2), but a %V week is only meaningful with the ISO year %G: the default year_format = "%Y" makes the view raise ValueError: 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 why BookWeekArchiveView sets both; test_iso_week_format_needs_the_iso_year_format asserts the error and test_year_month_day_week the working configuration. Week numbers printed with the date filter’s W character are not always equal to strftime()’s `%W, so do not build URLs from them (the manual says the same).

  • Time zones. If date_field is a DateTimeField and USE_TZ is 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 a DateField (as Book.published is) when the date is a calendar date, not an instant.

  • Each next_ and previous_* value costs a query* when allow_empty = False, because Django looks for the next period that has objects.

  • The archive views fetch everything for the period. MonthArchiveView has no pagination by default; add paginate_by for a busy period.

  • The order of date_list is 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).

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

ContextMixin.extra_context

A dict added to the context; handy in as_view(extra_context={…​}).

ContextMixin.get_context_data(**kwargs)

Returns the context dict; adds view. Every override should call super().get_context_data(**kwargs) first.

TemplateResponseMixin.template_name

Template to render. Not defining one (and not overriding get_template_names()) raises ImproperlyConfigured.

TemplateResponseMixin.template_engine

NAME of the template engine to use; None searches all configured engines.

TemplateResponseMixin.response_class

The class render_to_response() returns; default TemplateResponse. Subclass TemplateResponse and set this for custom loading or context instantiation.

TemplateResponseMixin.content_type

Content type of the response; None means text/html.

TemplateResponseMixin.render_to_response(context, **response_kwargs)

Builds the response; extra keyword arguments go to the response class.

TemplateResponseMixin.get_template_names()

List of template names to try; the first that exists wins. Default: [template_name].

Source: Simple mixins

Single-object mixins: SingleObjectMixin and SingleObjectTemplateResponseMixin

Used by DetailView, UpdateView, DeleteView and DateDetailView.

Member Purpose and default

model

The model; model = Foo is effectively queryset = Foo.objects.all() on the default manager.

queryset

A QuerySet of candidate objects; supersedes model. It is a class attribute holding a mutable value: call .all() or use get_queryset() (which clones it) before touching it, never mutate it directly.

slug_field, slug_url_kwarg

Field holding the slug ("slug") and URL keyword argument that carries it ("slug").

pk_url_kwarg

URL keyword argument carrying the primary key ("pk").

query_pk_and_slug

When True, get_object() filters by both primary key and slug. Default False. Mitigates guessable-URL (IDOR) attacks.

context_object_name, get_context_object_name(obj)

Context variable for the object; default is the lower-cased model name.

get_object(queryset=None)

Looks up by pk_url_kwarg, else by slug; uses get_queryset() when queryset is None.

get_queryset()

The queryset attribute (cloned) or model’s default manager `all(). The right place to restrict by user or request.

get_slug_field()

Returns slug_field.

get_context_data(**kwargs)

Requires self.object to be set (even if None) by the view; adds object and the context-object-name entry.

SingleObjectTemplateResponseMixin.template_name_field

Name of a field on the object whose value is a template name; used before the default if the field value is not None.

SingleObjectTemplateResponseMixin.template_name_suffix

"_detail" by default; _form and _confirm_delete in the editing views.

SingleObjectTemplateResponseMixin.get_template_names()

template_name if set, else the template_name_field value, else <app_label>/<model_name><suffix>.html.

If a view uses SingleObjectMixin outside the built-in views, set self.object yourself; get_context_data() depends on it.

Multiple-object mixins: MultipleObjectMixin and MultipleObjectTemplateResponseMixin

Used by ListView and all the archive views except DateDetailView.

Member Purpose and default

model, queryset, get_queryset()

As above. get_queryset() must return an iterable and may return a QuerySet (which enables QuerySet-specific behaviour such as ordering).

ordering, get_ordering()

order_by() arguments; no ordering added by default.

allow_empty, get_allow_empty()

True: show an empty page. False: 404 when no objects.

paginate_by, get_paginate_by(queryset)

Page size; None means no pagination.

paginate_orphans, get_paginate_orphans()

How many leftover objects the last page may absorb; default 0.

page_kwarg

Name of the page parameter (URL keyword or query string); default "page". Pages are 1-based and last is accepted.

paginator_class, get_paginator(queryset, per_page, orphans=0, allow_empty_first_page=True)

The paginator; default django.core.paginator.Paginator. Override get_paginator() if your class has a different constructor.

paginate_queryset(queryset, page_size)

Returns the 4-tuple (paginator, page, object_list, is_paginated); invalid pages raise Http404.

context_object_name, get_context_object_name(object_list)

Context name for the list; default <model>_list when the list is a QuerySet of model instances.

get_context_data(**kwargs)

Adds object_list, the named variable, is_paginated, paginator and page_obj. The last three are False/None when not paginated.

MultipleObjectTemplateResponseMixin.template_name_suffix

"_list".

MultipleObjectTemplateResponseMixin.get_template_names()

template_name (if set), then <app_label>/<model_name><suffix>.html.

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:

catalog/views.py — BookJsonListView
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().

Editing mixins: FormMixin, ModelFormMixin, ProcessFormView and DeletionMixin

Member Purpose and default

FormMixin.form_class, get_form_class()

The form class to instantiate.

FormMixin.initial, get_initial()

Initial data; get_initial() returns a copy.

FormMixin.prefix, get_prefix()

Prefix for the form’s field names.

FormMixin.success_url, get_success_url()

Redirect target after a valid form.

FormMixin.get_form(form_class=None)

Instantiates the form with get_form_kwargs().

FormMixin.get_form_kwargs()

initial and prefix, plus data and files for POST/PUT requests.

FormMixin.form_valid(form) / form_invalid(form)

Redirect to get_success_url() / re-render with the invalid form.

FormMixin.get_context_data()

Adds form if not already given.

ModelFormMixin.fields

List of field names, interpreted like Meta.fields; required when the form is generated. fields and form_class together raise ImproperlyConfigured.

ModelFormMixin.get_form_class()

The form_class if given, else a ModelForm generated for the model.

ModelFormMixin.get_form_kwargs()

Adds instance=self.object.

ModelFormMixin.success_url, get_success_url()

Accepts {field} placeholders filled from the object; falls back to get_absolute_url().

ModelFormMixin.form_valid(form)

Saves the form, sets self.object, redirects.

ProcessFormView.get() / post() / put()

Render the form / validate and dispatch to form_valid() or form_invalid() / put() passes everything to post().

DeletionMixin.success_url, get_success_url()

Redirect after deletion; accepts {field} placeholders.

DeletionMixin.delete()

Deletes get_object() and redirects. Handles the DELETE method (and post() of the bare mixin).

Source: Editing mixins

Date-based mixins

Member Purpose and default

YearMixin.year_format, year, get_year(), get_next_year(date), get_previous_year(date)

strftime() format ("%Y"); optional fixed value; the year from attribute, URL argument or query string (else 404); adjacent years (may be None or raise 404 depending on allow_empty/allow_future).

MonthMixin.month_format, month, get_month(), get_next_month(date), get_previous_month(date)

Same for months; default format "%b".

DayMixin.day_format, day, get_day(), get_next_day(date), get_previous_day(date)

Same for days; default format "%d".

WeekMixin.week_format, week, get_week(), get_next_week(date), get_prev_week(date)

Same for weeks; default "%U" (use "%W" or "%V" for Monday-based weeks).

DateMixin.date_field, get_date_field()

The DateField/DateTimeField the archive uses.

DateMixin.allow_future, get_allow_future()

Whether objects dated in the future appear; default False.

BaseDateListView.allow_empty

Default False (the opposite of MultipleObjectMixin).

BaseDateListView.date_list_period, get_date_list_period()

"year" (default), "month" or "day".

BaseDateListView.get_dated_items()

Returns (date_list, object_list, extra_context); the hook each concrete archive view implements.

BaseDateListView.get_dated_queryset(**lookup)

Filters the queryset with lookup and enforces allow_empty and allow_future.

BaseDateListView.get_date_list(queryset, date_type=None, ordering="ASC")

Dates of type date_type with entries; arguments go to QuerySet.dates().

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.

Mixins from other apps that belong in this family

  • django.contrib.auth.mixins.LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin and AccessMixin, described under Scoping querysets to request.user.

  • django.contrib.messages.views.SuccessMessageMixin: the success_message attribute and get_success_message(cleaned_data) method; used with every editing view on this page.

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, runserver without ASGI) everything is sync and nothing special happens.

  • Under ASGI (Uvicorn, Daphne, Granian; runserver with 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 check BookListView.view_is_async is False; for an async handler it is True.

  • Template rendering is always sync work, even for an async view: render_to_response() returns a lazy TemplateResponse, 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):

catalog/views.py — async display views
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))

The rules, each backed by something that ran:

  • All handlers of one class must be sync or all async. View.view_is_async inspects the handlers. ListView and DetailView only define get() (plus options(), which adapts), so overriding get() as async def is allowed. CreateView, UpdateView, FormView and DeleteView define post(), put() or delete() as sync methods, so an async get() on them fails when as_view() is called: ImproperlyConfigured: Mixed HTTP handlers must either be all sync or all async. (the last test of AsyncViewTests asserts 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()) inside async def raises SynchronousOnlyOperation. AsyncBookListView evaluates the queryset with async for and sets self.object_list to a list, and get_context_data() then does no queries. ListView pagination is sync-only; Django 6.0 added AsyncPaginator and AsyncPage, 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 for paginate_queryset() and for get_object():

ex/async_sync_orm.py (excerpt)
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
Output of python manage.py shell < ex/async_sync_orm.py
PaginatingAsyncList: 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.mixins are sync-only. They override dispatch() as a sync method and read request.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:

ex/urls_async.py
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())]
ex/async_login.py
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())
Output
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.

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.

catalog/views.py — an async-safe login check
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
catalog/tests.py — AsyncViewTests
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()

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.

Output of 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"
Output
<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.

The test class that proves the pagination warning for an unordered queryset:

catalog/tests.py — UnorderedWarningTests
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:

ex/misconfig.py (excerpt)
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())
Output of python manage.py shell < ex/misconfig.py
CreateView 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']

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 queryset (for example appending a filter in place)

Use get_queryset(), which clones; add filters on the clone.

self.object or an attribute set on self in one request appears in another

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 get_context_data() without super()

The object_list, paginator, form and view entries vanish. Always context = super().get_context_data(**kwargs).

Filtering in the template or in get_context_data() instead of get_queryset()

The template filter still serves hidden rows to anyone with the URL, and pagination counts the wrong total. Filter the queryset.

fields = "all" or exclude on a CreateView/UpdateView

A new model column becomes browser-writable. List fields explicitly, or use a ModelForm with an explicit Meta.fields.

Setting form.instance.owner after super().form_valid()

By then the object is saved without it (an IntegrityError for a required field). Assign before calling super().

Returning nothing from form_valid()

The view must return an HttpResponse; return super().form_valid(form) or your own redirect.

success_url = reverse("name") at class level

Fails at import (the URLconf is not loaded yet). reverse_lazy(), or override get_success_url().

Placeholders in success_url that name related objects

Only attributes of the saved object are available ({slug}, {author_id}), not {author.slug}. Override get_success_url() for anything else.

DeleteView without a way to leave GET harmless

It already does: a GET only renders the confirmation. Do not link "delete" to a URL that deletes on GET.

Custom deletion logic in a delete() override

Since Django 4.0 the POST path goes through form_valid(). Move the logic there or into a helper that both call.

LoginRequiredMixin not leftmost, or an extra dispatch() that uses request.user before it

Put access mixins first; anything touching request.user after them.

Using UserPassesTestMixin twice

Combine the tests into one test_func().

paginate_by on an unordered queryset

Set ordering or Meta.ordering; otherwise pages are unstable and Django warns.

month_format, week_format, allow_future, allow_empty left at their defaults

%b months, Sunday-based %U weeks, future items hidden and empty periods a 404. Check the date-view options table.

An async get() on a view with sync post()

ImproperlyConfigured; keep editing views sync.

self.request.user in an async handler

SynchronousOnlyOperation; await self.request.auser().

Using request.accepts("text/html") to branch between HTML and JSON

It is true for / and for a missing header. Prefer request.get_preferred_type([…​]) (Django 5.2).

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 LoginRequiredMixin first and the generic view last.

  • Put the rules in the places the framework already calls: query restrictions in get_queryset(), ownership in form_valid(), extra form arguments in get_form_kwargs(), validation in the form. A view whose post() 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 generics module (ListAPIView, ListCreateAPIView and 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_list and others in modules such as django.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); write path() or re_path() and the <int:pk> converters shown in the URLconf above.

  • DeleteView logic. Books that customise deletion by overriding delete() describe the pre-4.0 flow. Since 4.0, POST goes through form_valid() and a form, so the logic belongs there and SuccessMessageMixin finally 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 a CreateView without fields, which generated a form with every field; that is an ImproperlyConfigured error today (see the output above), and fields = "all" is a security anti-pattern, not a convenience. Specifying fields and form_class together has been an error since 1.8.

  • Permissions in views. Books that predate 1.9 wrap dispatch() in @method_decorator(login_required); use LoginRequiredMixin and PermissionRequiredMixin (added in 1.9) leftmost in the class. A book listing that puts a check in dispatch() 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; use render()/TemplateResponse, gettext and {% load static %}.

  • Books show GET links that change state (a "delete" or "add to basket" link). Those are CSRF holes; the generic editing views use POST with {% csrf_token %}.

What changed recently

Django Change relevant to generic views

6.1

RedirectView.preserve_request (the Generic Views section of the 6.1 notes); the generic views themselves are unchanged. Calendar versioning starts: deprecation warnings are now named after years (RemovedInDjango2028Warning).

6.0

AsyncPaginator and AsyncPage; a paginate_orphans/orphans value greater than or equal to the page size is deprecated.

5.2

HttpRequest.get_preferred_type() and a client-preference-sorted accepted_types, used by JsonableResponseMixin.

5.1

{% querystring %} for pagination links; LoginRequiredMiddleware and login_not_required(); the middleware respects login_url and redirect_field_name from the login_required() decorator but not from LoginRequiredMixin.

5.0

request.auser(), aforce_login() and aget_object_or_404()/aget_list_or_404(), the tools an async generic view needs.

4.1

The async ORM (aget(), async for) that async handlers use.

4.0

DeleteView uses FormMixin (confirmation forms, SuccessMessageMixin; logic moves from delete() to form_valid()); url() removed.

3.2

week_format = "%V" (ISO 8601 weeks) for WeekMixin and WeekArchiveView.

3.1

Async handlers on View subclasses.

2.2

View.setup().

2.0

ContextMixin.extra_context, usable from as_view().

1.9

The access mixins LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin and AccessMixin.

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.