Filter concepts by levelShowing all levels.

Django · Section 7

Class-Based Views

Level
advanced
Read
44 min
Concepts
5

The View base class and dispatch(), which routes a request to get()/post()/etc. automatically; the generic display views (TemplateView, ListView) and their get_context_data()/get_queryset() seams; DetailView and the three editing views (Create/Update/Delete); the form_valid()/form_invalid() fork every form-handling view hits; and how to compose mixins without the ordering silently breaking.

This section

What is true here

  1. MyView.as_view() returns a plain function; dispatch() (inherited, not written by hand) routes to get()/post()/etc. by request.method.
  2. TemplateView needs only template_name; ListView adds a queryset — override get_queryset() to filter it, always call super().get_context_data() first.
  3. DetailView/CreateView/UpdateView share ModelForm/get_object() plumbing; DeleteView splits GET (confirm) from POST (delete) by design.
  4. form_valid() runs after validation passes and must return a response (usually via super()); form_invalid() re-renders with errors by default.
  5. A mixin must be listed before the base view class — MRO resolves left to right — and each CBV should generally draw from one generic-view family.

What you will be able to do

  • Explain what as_view() and dispatch() each actually do
  • Choose between TemplateView, ListView, DetailView, and the editing views for a given task
  • Override get_queryset()/get_object()/get_context_data() correctly, including calling super()
  • Override form_valid()/get_form_kwargs() to add custom form-handling behavior
  • Order mixins correctly and recognize when a CBV combination has become too complex

The base mechanism

How every class-based view — generic or hand-written — turns into a callable urls.py can use, and routes a request once called.

The View base class and dispatch()

coreintermediate

Every class-based view subclasses View, whose as_view() returns a plain function Django can call like any FBV. dispatch() is what that function actually runs — it looks at request.method and calls the matching method (get(), post(), ...) on the instance.

Think of it as

as_view() is a small factory that hands urls.py an ordinary function, hiding the class entirely — from the URLconf's perspective, a CBV looks exactly like an FBV. Once called, that function creates an instance and hands control to dispatch(), which is a receptionist checking request.method against a name tag (get, post, put...) and routing the call to whichever method matches — or to http_method_not_allowed if none does.

python
from django.views import View
from django.http import HttpResponse

class Echo(View):
    def get(self, request):
        return HttpResponse("GET received")

    def post(self, request):
        return HttpResponse("POST received")

What we're doing: Write a minimal CBV directly on View, with distinct GET and POST handling, and see what happens when a method is missing.

accounts/views.pypython
class LoginView(View):
    def get(self, request):
        return render(request, "accounts/login.html")

    def post(self, request):
        # no put()/delete() defined — those fall through to a 405
        ...
        return redirect("dashboard")
3
get() handles displaying the login form; dispatch() routes here for any GET request to this URL.
6
No put()/delete() are defined on this class, so dispatch() sends those methods to http_method_not_allowed() automatically — no manual check required, unlike an FBV.

Why this works: A CBV gets its HTTP-method dispatch for free from the base class, unlike an FBV, where every method check has to be written by hand — this is the core reason CBVs help: request.method routing is a solved problem the moment a view class defines get()/post() as separate methods.

Forgetting to call .as_view() when registering a CBV in urls.py

Wrong

python
urlpatterns = [
    path("ping/", Ping),   # the class itself, not a callable Django can use
]

Better

python
urlpatterns = [
    path("ping/", Ping.as_view()),
]

What you see: TypeError: view must be a callable or a list/tuple in the case of include() — Django expects a function, not a class.

Why: .as_view() is what converts the class into the plain function urls.py needs — the class itself is never a valid view; it is a blueprint as_view() uses to build one instance per request.

How a request reaches get()/post()

urlpatterns

MyView.as_view()

dispatch()

checks request.method

self.get() / self.post()

  • urlpatterns — MyView.as_view()
    • leads to dispatch()
  • dispatch() — checks request.method
    • leads to self.get() / self.post()
  • self.get() / self.post()

What as_view() and dispatch() actually do

What as_view() and dispatch() actually do
StepWhat happens
MyView.as_view()returns a plain function, called `view`, closing over the class
view(request, ...)creates self = MyView(), then calls self.dispatch(request, ...)
dispatch()picks self.get/self.post/... by request.method.lower()
no matching methodfalls through to http_method_not_allowed() — a 405

Together

python
class Ping(View):
    def get(self, request):
        return HttpResponse("pong")

# urls.py
path("ping/", Ping.as_view())

Remember: MyView.as_view() returns a function urls.py can call; dispatch() (inherited, not written by hand) routes to get()/post()/etc. based on request.method — a missing method becomes an automatic 405.

See also: views · mixins and mro · http methods

Advertisement

Display and editing views

The generic views that cover the everyday CRUD shapes — list, detail, create, update, delete.

TemplateView and ListView

coreintermediate

TemplateView renders a template with no database work by default — override get_context_data() to add data. ListView renders a queryset of objects — override get_queryset() to filter it, and paginate_by to page it automatically.

Think of it as

TemplateView is the blank page — it renders template_name and whatever get_context_data() adds, nothing more. ListView is TemplateView plus one job: fetch a queryset (via get_queryset(), defaulting to model.objects.all()) and hand it to the template as context_object_name. Overriding get_context_data() on either always means calling super() first — it's not your context to build from scratch, it's the parent's context plus what you're adding.

python
class DashboardView(TemplateView):
    template_name = "dashboard.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["total_users"] = User.objects.count()
        return context

What we're doing: Filter a ListView's queryset per a URL parameter, and add the resolved object into context alongside the filtered list.

books/views.pypython
from django.shortcuts import get_object_or_404
from django.views.generic import ListView
from .models import Book, Publisher

class PublisherBookListView(ListView):
    template_name = "books/by_publisher.html"
    context_object_name = "book_list"

    def get_queryset(self):
        self.publisher = get_object_or_404(Publisher, slug=self.kwargs["publisher"])
        return Book.objects.filter(publisher=self.publisher)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["publisher"] = self.publisher
        return context
7
self.kwargs is where a CBV keeps the URL's captured segments — the same values an FBV would receive as function arguments.
12
self.publisher, set inside get_queryset(), is reused here rather than looked up a second time — get_queryset() always runs before get_context_data() in ListView's flow.

Why this works: Storing self.publisher as an instance attribute inside get_queryset() and reading it back in get_context_data() avoids querying the database twice for the same object — the two methods run in a fixed order within one request, so instance state set in the first is safely available in the second.

Overriding get_context_data() without calling super() first

Wrong

python
class BookListView(ListView):
    model = Book

    def get_context_data(self, **kwargs):
        return {"extra": "data"}   # the object_list/book_list key is gone

Better

python
class BookListView(ListView):
    model = Book

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["extra"] = "data"
        return context

What you see: The template renders with book_list (or object_list) undefined — an empty list where the whole page of books used to be — even though the view's queryset is correct.

Why: get_context_data() only contains what it is given — ListView's own implementation is what puts the queryset's results into context in the first place. Skipping super() replaces the entire context dict instead of adding to it, silently discarding whatever the parent class already built.

TemplateView vs. ListView

TemplateView

  • +Needs only template_name
  • +No database work by default
  • +Customize via get_context_data()

ListView

  • Needs model or queryset
  • context_object_name defaults to "<model>_list"
  • Customize via get_queryset(); paginate_by for paging
  • TemplateView
    • Needs only template_name
    • No database work by default
    • Customize via get_context_data()
  • ListView
    • Needs model or queryset
    • context_object_name defaults to "<model>_list"
    • Customize via get_queryset(); paginate_by for paging

TemplateView vs ListView

TemplateView vs ListView
AspectTemplateViewListView
Requirestemplate_namemodel or queryset
Default context keynone"<model>_list" (or context_object_name)
Customize the dataget_context_data()get_queryset()
Paginationnot built inpaginate_by, automatic

Together

python
class BookListView(ListView):
    queryset = Book.objects.order_by("-publication_date")
    context_object_name = "book_list"
    paginate_by = 10

Remember: TemplateView needs only template_name; ListView adds a queryset — override get_queryset() to filter it, and always call super().get_context_data() first when adding extra context.

See also: view and dispatch · detail and editing views · templates

DetailView, CreateView, UpdateView, DeleteView

coreintermediate

DetailView fetches a single object via get_object() (by pk_url_kwarg, default "pk"). CreateView/UpdateView both wrap a ModelForm and redirect to success_url on save; DeleteView shows a confirmation on GET and deletes on POST.

Think of it as

DetailView is ListView's single-object sibling — get_object() instead of get_queryset(). CreateView and UpdateView are the same view underneath, both built on a ModelForm: CreateView starts with self.object = None, UpdateView starts with self.object already fetched — everything else (form validation, save, redirect) is shared. DeleteView is the one editing view where GET and POST mean different things by design: GET shows "are you sure?", POST actually deletes.

python
class ArticleUpdateView(UpdateView):
    model = Article
    fields = ["title", "body"]
    success_url = reverse_lazy("articles:list")

What we're doing: Override get_object() on a DetailView to add a side effect (recording a view count) without changing how the object is otherwise looked up.

articles/views.pypython
class ArticleDetailView(DetailView):
    model = Article

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        Article.objects.filter(pk=obj.pk).update(view_count=F("view_count") + 1)
        return obj
4
super().get_object(queryset) still performs the normal pk-based lookup — this override only adds behavior around it, never replaces the lookup itself.
5
F("view_count") + 1 is a database-side increment (see Query Expressions) rather than obj.view_count += 1; obj.save() — avoids a race between two simultaneous page views.

Why this works: Overriding get_object() rather than get_queryset() is the correct hook specifically because DetailView calls get_object() exactly once, at the point the single object is actually needed — it is the natural place for a per-view side effect like a view counter that should fire once per detail-page visit.

Deleting on GET by putting the delete logic in get() instead of relying on DeleteView's POST-only default

Wrong

python
class ArticleDeleteView(DeleteView):
    model = Article
    success_url = reverse_lazy("articles:list")

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        self.object.delete()   # deletes on a GET request
        return redirect(self.success_url)

Better

python
class ArticleDeleteView(DeleteView):
    model = Article
    success_url = reverse_lazy("articles:list")
    # no get() override — the inherited one renders a confirmation template;
    # deletion happens only in the inherited post()

What you see: A crawler, browser prefetch, or accidental double-click on a link — anything that triggers a GET — deletes the object without any confirmation step.

Why: DeleteView's built-in behavior deliberately splits GET (show a confirmation page) from POST (actually delete) for the same reason any state-changing FBV should reject GET — HTTP's own semantics expect GET to be safe. Overriding get() to delete immediately throws away that safety net for no benefit over the default.

What GET and POST do on each editing view
DetailView (GET)
renders the object
CreateView (GET)
renders a blank form
UpdateView (GET)
renders pre-filled form
DeleteView (GET)
renders confirmation only
CreateView/UpdateView (POST)
validates and saves
DeleteView (POST)
deletes and redirects
  • DetailView (GET): read-only, renders a form/page — renders the object
  • CreateView (GET): between read-only and destructive, renders a form/page — renders a blank form
  • UpdateView (GET): between read-only and destructive, renders a form/page — renders pre-filled form
  • DeleteView (GET): destructive, renders a form/page — renders confirmation only
  • CreateView/UpdateView (POST): between read-only and destructive, commits a change — validates and saves
  • DeleteView (POST): destructive, commits a change — deletes and redirects

DetailView and the three editing views

DetailView and the three editing views
ViewGET doesPOST does
DetailViewrenders the object— (read-only)
CreateViewrenders a blank formvalidates and creates
UpdateViewrenders form pre-filled from get_object()validates and saves
DeleteViewrenders a confirmation pagedeletes and redirects

Together

python
class ArticleDetailView(DetailView):
    model = Article
    pk_url_kwarg = "article_id"   # URL uses <int:article_id>, not <int:pk>

Remember: DetailView/CreateView/UpdateView share get_object()/ModelForm plumbing; DeleteView deliberately splits GET (confirm) from POST (actually delete) — never delete inside get().

See also: template and list views · form handling · models

Advertisement

Form handling and composing mixins

The fork every form-backed view hits on submission, and how to combine mixins without the ordering silently breaking.

FormView and the form-handling methods

coreintermediate

FormView processes a plain Form (not tied to a model) — get_form() builds it, form_valid(form) runs on success (default: redirect to success_url), form_invalid(form) runs on failure (default: re-render with errors). CreateView/UpdateView reuse this exact same flow underneath.

Think of it as

FormMixin's flow is the same fork in the road every time: build the form, check is_valid(), then go one of two ways. form_valid() is the door marked success — override it to do something with cleaned_data before or after the default redirect. form_invalid() is the door marked try again — override it only when the default (re-render with the form's own errors attached) isn't enough.

python
class SignupView(FormView):
    template_name = "signup.html"
    form_class = SignupForm
    success_url = reverse_lazy("login")

    def form_valid(self, form):
        form.save()
        return super().form_valid(form)

What we're doing: Pass the logged-in user into a form's constructor by overriding get_form_kwargs(), so the form can validate against that user's own data.

billing/views.pypython
class InvoiceCreateView(FormView):
    form_class = InvoiceForm

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["user"] = self.request.user
        return kwargs

    def form_valid(self, form):
        form.save(created_by=self.request.user)
        return super().form_valid(form)
4
get_form_kwargs() is the method get_form() itself calls to build the constructor arguments — overriding it is the documented way to pass something extra without reimplementing get_form().
6
super().get_form_kwargs() first preserves everything FormMixin already passes (data, files, initial) — only user is being added, not replacing the rest.

Why this works: InvoiceForm needs the current user to validate against (e.g. "does this user have permission to bill this account?"), but FormMixin has no built-in way to know about request.user — get_form_kwargs() is specifically the seam designed for passing exactly this kind of extra, per-request constructor argument into an otherwise-standard form.

Overriding form_valid() without calling super() or returning a response

Wrong

python
class ContactView(FormView):
    form_class = ContactForm
    success_url = "/thanks/"

    def form_valid(self, form):
        send_contact_email(form.cleaned_data)
        # no return — implicitly returns None

Better

python
class ContactView(FormView):
    form_class = ContactForm
    success_url = "/thanks/"

    def form_valid(self, form):
        send_contact_email(form.cleaned_data)
        return super().form_valid(form)   # performs the redirect

What you see: ValueError: The view ...ContactView didn't return an HttpResponse object. It returned None instead — the same failure mode as any view returning nothing.

Why: form_valid() is still an ordinary view method under the same "must return an HttpResponse" contract every view follows — the base implementation's redirect to success_url only happens if it actually runs, which means an override has to call and return super().form_valid(form), not just perform its own side effect and stop.

FormMixin's fork in the road
TrueFalse

get_form()

always runs, GET and POST

form.is_valid()

form_valid(form)

default: redirect to success_url

form_invalid(form)

default: re-render with errors

  • get_form() — always runs, GET and POST
    • leads to form.is_valid()
  • form.is_valid()
    • leads to form_valid(form) (True)
    • on error, leads to form_invalid(form) (False)
  • form_valid(form) — default: redirect to success_url
  • form_invalid(form) — default: re-render with errors

The form-handling methods, in the order they run

The form-handling methods, in the order they run
MethodRuns whenDefault behavior
get_form()always, on GET and POSTinstantiates form_class with request data (if POST)
form.is_valid()called by post() internallyruns field + clean() validation
form_valid(form)validation passedredirect() to success_url
form_invalid(form)validation failedre-render template with the bound, invalid form

Together

python
class ContactView(FormView):
    template_name = "contact.html"
    form_class = ContactForm
    success_url = "/thanks/"

    def form_valid(self, form):
        send_contact_email(form.cleaned_data)
        return super().form_valid(form)   # still does the redirect

Remember: form_valid() runs only after validation passes (return super() to keep the redirect); form_invalid() runs on failure (default: re-render with errors) — both must return an HttpResponse like any view method.

See also: detail and editing views · forms · response helpers

Mixins, MRO, and when CBVs help vs confuse

coreadvanced

A mixin is a small class providing one piece of behavior, meant to be combined via multiple inheritance — Python resolves method calls left-to-right through the class list (MRO), so a mixin must be listed BEFORE the base view class or its methods never get found first.

Think of it as

MRO is a search order, not a merge — Python doesn't combine two classes' get_context_data() into one, it picks the FIRST one found scanning the inheritance list left to right. A mixin listed after the base view is search order too late: the base class's own version is already found by then. This is exactly why the docs' own combining rule exists — one mixin from one logical family (detail, list, editing) at a time is easy to reason about; stacking several from different families multiplies which get_context_data()/get() actually runs, until nobody can tell by reading the class definition alone.

python
class LoginRequiredListView(LoginRequiredMixin, ListView):
    model = Article
    login_url = "/accounts/login/"

What we're doing: Combine SingleObjectMixin with ListView to build a "detail page with a paginated related list" view, getting the mixin order and the manual get_object() call both right.

books/views.pypython
class PublisherDetailView(SingleObjectMixin, ListView):
    paginate_by = 2
    template_name = "books/publisher_detail.html"

    def get(self, request, *args, **kwargs):
        self.object = self.get_object(queryset=Publisher.objects.all())
        return super().get(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["publisher"] = self.object
        return context

    def get_queryset(self):
        return self.object.book_set.all()
1
SingleObjectMixin listed first means its get_object() is what "wins" in the MRO — but nothing calls it automatically, hence the manual get() override.
5
get_object() must run and set self.object BEFORE super().get() triggers ListView's own flow, which calls get_queryset() — reading self.object there depends entirely on this ordering.

Why this works: Neither mixin knows about the other's existence — SingleObjectMixin has no idea ListView.get() is about to call get_queryset(), and ListView has no idea a "current object" concept exists at all. The manual get() override is what actually wires the two together, in the one order that makes self.object available when get_queryset() needs it.

Listing the base view class before the mixin

Wrong

python
class BrokenPublisherView(ListView, SingleObjectMixin):
    paginate_by = 2

    def get_queryset(self):
        return self.object.book_set.all()   # self.object never gets set

Better

python
class PublisherDetailView(SingleObjectMixin, ListView):
    paginate_by = 2

    def get(self, request, *args, **kwargs):
        self.object = self.get_object(queryset=Publisher.objects.all())
        return super().get(request, *args, **kwargs)

    def get_queryset(self):
        return self.object.book_set.all()

What you see: AttributeError: 'BrokenPublisherView' object has no attribute 'object' — raised inside get_queryset(), before the template ever renders.

Why: With ListView listed first, ListView.get() runs (from the MRO) without ever calling SingleObjectMixin's get_object() — nothing sets self.object before get_queryset() tries to read it. Swapping the order alone doesn't fix it either; the get() override that actually calls get_object() is what's required, the ordering only makes that override's own method resolution correct.

MRO resolves left to right — first listed, first checked

SingleObjectMixin

listed first — its get_object() wins

ListView

checked next — its get() would otherwise run

View (base)

checked last

  1. SingleObjectMixin — listed first — its get_object() wins
  2. ListView — checked next — its get() would otherwise run
  3. View (base) — checked last

Reading a class declaration for MRO order

Reading a class declaration for MRO order
DeclarationChecked first for a methodCorrect when
class MyView(SingleObjectMixin, ListView)SingleObjectMixinthe mixin's methods (get_object) must run before ListView's own
class MyView(ListView, SingleObjectMixin)ListViewalmost never — ListView's get_queryset() runs first, before self.object exists

Together

python
class PublisherDetailView(SingleObjectMixin, ListView):
    paginate_by = 2

    def get(self, request, *args, **kwargs):
        self.object = self.get_object(queryset=Publisher.objects.all())
        return super().get(request, *args, **kwargs)

    def get_queryset(self):
        return self.object.book_set.all()   # self.object must already exist

Remember: List a mixin before the base view class in the parentheses — MRO resolves left to right. Stick to mixins from one generic-view family (detail, list, editing, date) at a time; reach for two separate views before a deeply mixed CBV.

See also: view and dispatch · template and list views · detail and editing views

Advertisement