ZetCode

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.

config/settings.py
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.

books/models.py
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.

books/urls.py
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.

config/urls.py
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.

books/views.py
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.

books/templates/books/about.html
<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.

books/templates/books/book_list.html
<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.

books/templates/books/book_detail.html
<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.

books/templates/books/book_form.html
<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.

books/templates/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.

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().

books/tests.py
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.