Django class-based views
last modified November 13, 2026 Python 3.15
In this article we write Django class-based views. We start with the base
View class and go through TemplateView,
ListView, DetailView, CreateView,
UpdateView, and DeleteView. We also write a mixin and
customize the generic views. The examples use Django 6.1 and Python 3.15.
Class-based views
A class-based view (CBV) is a view written as a class instead of
a function. Django calls the as_view() class method to turn the
class into a callable that a URL pattern can use. On every request the
callable creates a new instance of the class and calls its
dispatch() method, which selects the handler method according to
the HTTP method: get() for GET, post()
for POST, and so on. A method that is not defined gives a 405 (Method Not Allowed) response.
Django ships generic views for the common tasks: showing a page, listing objects, showing one object, and creating, updating, and deleting an object. A generic view needs only a few attributes. We compare the idea with function-based views from Django views: a function-based view is explicit and simple, while a class-based view reuses code through inheritance and mixins.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, and start the app books.
$ mkdir cbv $ cd cbv $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp books
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'books',
]
Every listing in this article is a file of the application, shown with a solid blue border and the label Project file.
We build a small bookshelf. The model describes a book. Its
get_absolute_url() method returns the URL of the detail page; the
generic views use it as the default redirect target.
from django.db import models
from django.urls import reverse
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.CharField(max_length=100)
year = models.PositiveSmallIntegerField()
class Meta:
ordering = ['title']
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('books:detail', kwargs={'pk': self.pk})
The Meta.ordering option sorts the books by title by default.
We create the table and two books. The model is described in detail in a
later article.
$ uv run python manage.py makemigrations books
Migrations for 'books':
books/migrations/0001_initial.py
+ Create model Book
$ uv run python manage.py migrate books
Operations to perform:
Apply all migrations: books
Running migrations:
Applying books.0001_initial... OK
$ uv run python manage.py shell --no-imports -c "from books.models import Book; Book.objects.create(title='Dune', author='Frank Herbert', year=1965); Book.objects.create(title='Emma', author='Jane Austen', year=1815)"
URL configuration
The URLconf of the app uses as_view() to register the views.
The project's urls.py includes this file at the site root.
from django.urls import path
from django.views.generic import TemplateView
from . import views
app_name = 'books'
urlpatterns = [
path('hello/', views.HelloView.as_view(), name='hello'),
path('about/', TemplateView.as_view(
template_name='books/about.html',
extra_context={'site': 'Bookshelf'}), name='about'),
path('books/', views.BookListView.as_view(), name='list'),
path('books/new/', views.BookCreateView.as_view(), name='create'),
path('books/<int:pk>/', views.BookDetailView.as_view(), name='detail'),
path('books/<int:pk>/edit/', views.BookUpdateView.as_view(), name='update'),
path('books/<int:pk>/delete/', views.BookDeleteView.as_view(), name='delete'),
]
Every class-based view is registered with Class.as_view(). The
as_view() method also accepts keyword arguments that override the
attributes of the class, as we do for TemplateView in the
about/ route. The URL names are in the books
namespace (see Django URL routing). The project's
urls.py follows.
from django.urls import include, path
urlpatterns = [
path('', include('books.urls')),
]
The empty prefix mounts all URLs of the app at the root of the site.
The views module
The views of the app live in one module. We show it in full here; the next sections describe the classes one by one.
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.views import View
from django.views.generic import (CreateView, DeleteView, DetailView,
ListView, UpdateView)
from .models import Book
class PageTitleMixin:
page_title = ''
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = self.page_title
return context
class HelloView(View):
def get(self, request):
return HttpResponse('GET: hello\n', content_type='text/plain')
def post(self, request):
return HttpResponse('POST: hello\n', content_type='text/plain')
class BookListView(PageTitleMixin, ListView):
model = Book
context_object_name = 'books'
page_title = 'All books'
def get_queryset(self):
queryset = super().get_queryset()
query = self.request.GET.get('q')
if query:
queryset = queryset.filter(title__icontains=query)
return queryset
class BookDetailView(DetailView):
model = Book
class BookCreateView(PageTitleMixin, CreateView):
model = Book
fields = ['title', 'author', 'year']
page_title = 'New book'
class BookUpdateView(PageTitleMixin, UpdateView):
model = Book
fields = ['title', 'author', 'year']
page_title = 'Edit book'
class BookDeleteView(DeleteView):
model = Book
success_url = reverse_lazy('books:list')
The mixin PageTitleMixin adds a title to the context of the views that
use it, and the other classes are generic views configured with a few attributes. The model
and the templates are the ones from this article.
The View class
The base class django.views.View only implements the dispatching.
We add a handler method for each HTTP method that the view supports.
class HelloView(View):
def get(self, request):
return HttpResponse('GET: hello\n', content_type='text/plain')
def post(self, request):
return HttpResponse('POST: hello\n', content_type='text/plain')
The handler methods receive the request, followed by the captured URL
values, and return a response, as a function view does. The class also stores the request in
self.request and the URL arguments in self.args and
self.kwargs, so all methods of the class can use them. Django
automatically adds a head() handler that calls get()
and an options() handler that returns the allowed methods in the
Allow header. The -I option of curl sends a
HEAD request and the -X OPTIONS option an
OPTIONS request; the -i option includes the response
headers.
$ curl -s http://127.0.0.1:8000/hello/ GET: hello $ curl -s -I http://127.0.0.1:8000/hello/ | head -1 HTTP/1.1 200 OK $ curl -s -i -X OPTIONS http://127.0.0.1:8000/hello/ | grep -i "^allow" Allow: GET, POST, HEAD, OPTIONS
TemplateView
The TemplateView renders a template on GET
requests. It is meant for pages without a model, such as an about page. The
template name and the extra context can be given directly in
as_view(), so no subclass is needed.
path('about/', TemplateView.as_view(
template_name='books/about.html',
extra_context={'site': 'Bookshelf'}), name='about'),
The extra_context dictionary is added to the template
context. The template below prints the site value from it.
<h1>About {{ site }}</h1>
<p>A small collection of books.</p>
The generated page contains the value from the dictionary.
$ curl -s http://127.0.0.1:8000/about/ <h1>About Bookshelf</h1> <p>A small collection of books.</p>
ListView
The ListView shows a list of objects. We set the
model attribute; the view then uses Book.objects.all()
as the queryset. By default, the list is available in the template as
object_list and book_list (the model name plus
_list); the context_object_name attribute lets us choose
a different name. The default template name is made from the app and the model:
books/book_list.html.
class BookListView(PageTitleMixin, ListView):
model = Book
context_object_name = 'books'
page_title = 'All books'
def get_queryset(self):
queryset = super().get_queryset()
query = self.request.GET.get('q')
if query:
queryset = queryset.filter(title__icontains=query)
return queryset
The get_queryset() method is the place to filter the
data; here it narrows the list to the books whose title contains the text from
the q query parameter. The PageTitleMixin adds the page
title to the context; we show it in the section about mixins. The template
iterates over books.
<h1>{{ page_title }}</h1>
<ul>
{% for book in books %}
<li><a href="{{ book.get_absolute_url }}">{{ book.title }}</a> ({{ book.author }}, {{ book.year }})</li>
{% empty %}
<li>No books found.</li>
{% endfor %}
</ul>
<p><a href="{% url 'books:create' %}">Add a book</a></p>
The {{ book.get_absolute_url }} expression calls the model
method to get the link. The first request lists all books, which are sorted by
the title, and the second one filters them with ?q=emm.
$ curl -s http://127.0.0.1:8000/books/ <h1>All books</h1> <ul> <li><a href="/books/1/">Dune</a> (Frank Herbert, 1965)</li> <li><a href="/books/2/">Emma</a> (Jane Austen, 1815)</li> </ul> <p><a href="/books/new/">Add a book</a></p> $ curl -s "http://127.0.0.1:8000/books/?q=emm" <h1>All books</h1> <ul> <li><a href="/books/2/">Emma</a> (Jane Austen, 1815)</li> </ul> <p><a href="/books/new/">Add a book</a></p>
DetailView
The DetailView shows one object. It looks the object up by the
pk (or slug) URL keyword argument and returns 404 when
there is none. The context contains the object as object and under
the model name, book; the default template is
books/book_detail.html.
class BookDetailView(DetailView):
model = Book
Two lines are enough. The template prints the fields and links to the edit and delete pages.
<h1>{{ book.title }}</h1>
<p>{{ book.author }}, {{ book.year }}</p>
<p>
<a href="{% url 'books:update' book.pk %}">Edit</a>
<a href="{% url 'books:delete' book.pk %}">Delete</a>
</p>
A request for an existing book renders the page; a request for a missing book gives 404.
$ curl -s http://127.0.0.1:8000/books/1/
<h1>Dune</h1>
<p>Frank Herbert, 1965</p>
<p>
<a href="/books/1/edit/">Edit</a>
<a href="/books/1/delete/">Delete</a>
</p>
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/books/99/
404
CreateView
The CreateView shows a form on GET and saves the new
object on a valid POST. Django builds a ModelForm from
the model and fields attributes, so we do not write a
form class. When the form is valid, the view saves the object and redirects to
success_url; if the attribute is not set, the
get_absolute_url() of the new object is used. When the form is
invalid, the form is displayed again with the error messages. The default
template is books/book_form.html, shared with the
UpdateView.
class BookCreateView(PageTitleMixin, CreateView):
model = Book
fields = ['title', 'author', 'year']
page_title = 'New book'
The form template renders the form with form.as_div, which
wraps every field with its label and errors in a div, and includes
the CSRF token.
<h1>{{ page_title }}</h1>
<form method="post">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">Save</button>
</form>
The request below shows the form for a new book. The CSRF token differs on
every request, so we replaced the value of the hidden field with dots; we
also removed the blank lines that the template engine leaves. The form
fields come from the model: the CharField instances become text
inputs limited by maxlength and the
PositiveSmallIntegerField becomes a number input.
$ curl -s http://127.0.0.1:8000/books/new/
<h1>New book</h1>
<form method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="...">
<div>
<label for="id_title">Title:</label>
<input type="text" name="title" maxlength="100" required id="id_title">
</div>
<div>
<label for="id_author">Author:</label>
<input type="text" name="author" maxlength="100" required id="id_author">
</div>
<div>
<label for="id_year">Year:</label>
<input type="number" name="year" min="0" required id="id_year">
</div>
<button type="submit">Save</button>
</form>
UpdateView and DeleteView
The UpdateView works like the CreateView, but it
first loads the object by its key and fills the form with the current values.
The DeleteView shows a confirmation page on GET and
deletes the object on POST. It must know where to go after the
deletion, since the object no longer has a URL, so we set
success_url. We use reverse_lazy() instead of
reverse(), because the attribute is evaluated when the module is
imported, before the URL configuration is loaded.
class BookUpdateView(PageTitleMixin, UpdateView):
model = Book
fields = ['title', 'author', 'year']
page_title = 'Edit book'
class BookDeleteView(DeleteView):
model = Book
success_url = reverse_lazy('books:list')
The default template of the DeleteView is
books/book_confirm_delete.html.
<h1>Delete {{ book.title }}?</h1>
<form method="post">
{% csrf_token %}
<button type="submit">Yes, delete</button>
</form>
The confirmation page asks the user and posts an empty form. We do not call
the update and delete views with curl, because their POST requests
need a CSRF token; the tests in the last section cover them. The request below
shows the confirmation page.
$ curl -s http://127.0.0.1:8000/books/1/delete/ <h1>Delete Dune?</h1> <form method="post"> <input type="hidden" name="csrfmiddlewaretoken" value="..."> <button type="submit">Yes, delete</button> </form>
Mixins
Generic views are built from small classes called mixins, each of
which provides one piece of behavior, such as
SingleObjectMixin (finding an object) or
TemplateResponseMixin (rendering a template). We can write our own.
A mixin is a plain class without a base; it must be listed before the generic
view in the bases, because Python resolves methods from left to right.
class PageTitleMixin:
page_title = ''
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = self.page_title
return context
The mixin extends get_context_data(), the method that every
generic view uses to build the template context. It calls the parent
implementation with super() and adds the page_title
value taken from the class attribute. The list, create, and update views use
the mixin, and each sets its own page_title, which is why the list
page above has the heading All books. The same
mechanism is used by the authentication mixins that Django provides, such as
LoginRequiredMixin and PermissionRequiredMixin; they
must also come first in the list of bases.
Methods to override
Generic views have many hooks. The most useful ones are the following.
get_queryset()- the objects the view works with; filter them by user or by query parametersget_context_data()- the template contextget_object()- how a single object is foundget_success_url()- the redirect target after a successful form; use it when the URL depends on the saved objectform_valid()andform_invalid()- what happens with a valid or an invalid formget_template_names()- the template to renderdispatch()- runs before every handler method; a good place for checks that apply to all methods
The Django documentation lists the attributes and methods of every generic view in its class-based views reference; the website Classy Class-Based Views shows the complete method resolution of each class at a glance.
Testing class-based views
Class-based views are tested through the test client in the same way as
function-based views. The tests below cover the search in the list, a missing
book, creating a book with valid and invalid data, updating and deleting a book,
and the methods of HelloView: PUT is not defined and
gives 405, while HEAD works because Django maps it to
get().
from django.test import TestCase
from django.urls import reverse
from .models import Book
class BookViewTests(TestCase):
def setUp(self):
self.book = Book.objects.create(title='Dune', author='Frank Herbert', year=1965)
Book.objects.create(title='Emma', author='Jane Austen', year=1815)
def test_list_search(self):
response = self.client.get(reverse('books:list'), {'q': 'du'})
self.assertContains(response, 'Dune')
self.assertNotContains(response, 'Emma')
def test_detail_missing_book(self):
response = self.client.get(reverse('books:detail', args=[999]))
self.assertEqual(response.status_code, 404)
def test_create_redirects_to_detail(self):
data = {'title': 'Ulysses', 'author': 'James Joyce', 'year': 1922}
response = self.client.post(reverse('books:create'), data)
book = Book.objects.get(title='Ulysses')
self.assertRedirects(response, book.get_absolute_url())
def test_create_with_invalid_year_shows_errors(self):
data = {'title': 'Bad', 'author': 'Nobody', 'year': 'abc'}
response = self.client.post(reverse('books:create'), data)
self.assertContains(response, 'Enter a whole number.')
self.assertFalse(Book.objects.filter(title='Bad').exists())
def test_update(self):
data = {'title': 'Dune Messiah', 'author': 'Frank Herbert', 'year': 1969}
self.client.post(reverse('books:update', args=[self.book.pk]), data)
self.book.refresh_from_db()
self.assertEqual(self.book.title, 'Dune Messiah')
def test_delete(self):
response = self.client.post(reverse('books:delete', args=[self.book.pk]))
self.assertRedirects(response, reverse('books:list'))
self.assertFalse(Book.objects.filter(pk=self.book.pk).exists())
def test_hello_view_methods(self):
self.assertEqual(self.client.put(reverse('books:hello')).status_code, 405)
self.assertEqual(self.client.head(reverse('books:hello')).status_code, 200)
The assertRedirects() calls check the redirect to the
get_absolute_url() of the created book and to the list after the
deletion. When the year is not a number, the response is a normal page (status 200) with
the form error; the invalid book is not saved.
$ uv run python manage.py test Found 7 test(s). System check identified no issues (0 silenced). Creating test database for alias 'default'... ....... ---------------------------------------------------------------------- Ran 7 tests in 0.020s OK Destroying test database for alias 'default'...
Source
Django documentation: Class-based views
Django documentation: Built-in class-based views API
In this article we have used the base View class and the
generic TemplateView, ListView,
DetailView, CreateView, UpdateView, and
DeleteView, and we have customized them with a mixin and
overridden methods. The views render templates, which are the topic of
Django templates; the routes are described in
Django URL routing.
Author
List all Django tutorials.