Django Intermediate #1: Class-Based Views in Depth
If you’ve finished the 7 posts of Django Basics, it’s time to step up. The intermediate series is seven posts that take the tools we only touched on in basics and go deep.
- #1 Class-Based Views in depth ← this post
- #2 ORM intermediate — annotate, aggregate, F/Q, prefetch_related
- #3 Signals and Middleware
- #4 Users/permissions — custom user model
- #5 Messages / sessions / cookies
- #6 Static/Media operations and storage backends
- #7 Testing — TestCase, fixtures, pytest-django
The first topic is Class-Based Views (CBV). This post takes the function-based views (FBV) from Basics #4 and rewrites them as reusable classes.
FBV vs CBV — why classes #
Function-based views from Basics #4 are intuitive. One function takes a request and returns a response. But once you start repeating common patterns like CRUD across many models, similar code keeps piling up.
| FBV | CBV | |
|---|---|---|
| Definition | One function | Class + methods |
| Learning curve | Very low | A little higher |
| HTTP method dispatch | if request.method == 'POST' | def get, def post |
| Reuse | Decorators / function decomposition | Inheritance / Mixin |
| Generic CRUD | Implement yourself | Built-in (ListView etc.) |
| Code flow tracing | Top-to-bottom, clear | Need to follow parent classes |
The strength of CBV is that common patterns live in the parent class and you only override the differences. The downside is that flow goes through parent methods, so at first it’s hard to trace where things actually happen.
One rule: simple routes (health checks, simple redirects, small forms) → FBV, structured patterns like CRUD / list / detail → CBV is usually the answer. There’s no need to unify on one side.
CBV starting point — View
#
The most basic class is django.views.View. You define get, post, put, delete methods per HTTP method.
from django.http import HttpResponse
from django.views import View
class HelloView(View):
def get(self, request):
return HttpResponse("Hello, GET")
def post(self, request):
return HttpResponse("Hello, POST")from django.urls import path
from .views import HelloView
urlpatterns = [
path('hello/', HelloView.as_view(), name='hello'),
]HelloView.as_view() converts a class into a callable view function. URL patterns always take a callable.
dispatch — the entry point for every request
#
In CBV, dispatch is called once before the per-method dispatch happens. You can put common processing (auth, logging, etc.) here.
class HelloView(View):
def dispatch(self, request, *args, **kwargs):
print(f"method: {request.method}")
return super().dispatch(request, *args, **kwargs)
def get(self, request):
return HttpResponse("GET")TemplateView and RedirectView
#
Learn the two simplest CBVs first and the pattern will click.
from django.views.generic import TemplateView
class AboutView(TemplateView):
template_name = "blog/about.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["page_title"] = "About Us"
return ctxfrom django.views.generic import RedirectView
class GoToBlogView(RedirectView):
pattern_name = "post_list" # url name
permanent = False
query_string = True # carry query string forwardget_context_data is where you build the template context. Almost every CBV has this method.
Generic CBV — built-in CRUD #
This is where CBV really shines. These are classes Django ships pre-built for common CRUD patterns.
ListView — list page
#
Let’s rewrite the post list from Basics #4 — written there as an FBV — as a CBV.
from django.shortcuts import render
from .models import Post
def post_list(request):
posts = Post.objects.filter(published=True).order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
model = Post
template_name = "blog/post_list.html"
context_object_name = "posts"
paginate_by = 10
ordering = ["-created_at"]
def get_queryset(self):
return super().get_queryset().filter(published=True)Key class attributes:
model— which model’s list this istemplate_name— if omitted, defaults to<app>/<model>_list.htmlcontext_object_name— the variable name in the template (defaultobject_list)paginate_by— automatic pagination (Django passespage_obj,paginatorin the context)ordering— default ordering
Override get_queryset to apply additional filters. It’s also a clean place to handle search conditions from URL query strings.
class PostListView(ListView):
model = Post
paginate_by = 10
def get_queryset(self):
qs = super().get_queryset().filter(published=True)
q = self.request.GET.get("q")
if q:
qs = qs.filter(title__icontains=q)
return qsDetailView — single record lookup
#
from django.views.generic import DetailView
class PostDetailView(DetailView):
model = Post
template_name = "blog/post_detail.html"
context_object_name = "post"
slug_field = "slug"
slug_url_kwarg = "slug"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["related"] = Post.objects.filter(
category=self.object.category
).exclude(pk=self.object.pk)[:5]
return ctxurlpatterns = [
path('posts/<int:pk>/', PostDetailView.as_view(), name='post_detail'),
# or slug-based
path('posts/<slug:slug>/', PostDetailView.as_view(), name='post_detail'),
]DetailView automatically looks up the object by the URL’s pk or slug. If not found, it returns 404 automatically.
CreateView, UpdateView, DeleteView
#
The ModelForm pattern from Basics #6 is automated.
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from .forms import PostForm
class PostCreateView(CreateView):
model = Post
form_class = PostForm
template_name = "blog/post_form.html"
success_url = reverse_lazy("post_list")
def form_valid(self, form):
form.instance.author = self.request.user
return super().form_valid(form)class PostUpdateView(UpdateView):
model = Post
form_class = PostForm
template_name = "blog/post_form.html"
def get_success_url(self):
return reverse_lazy("post_detail", kwargs={"slug": self.object.slug})class PostDeleteView(DeleteView):
model = Post
template_name = "blog/post_confirm_delete.html"
success_url = reverse_lazy("post_list")Key override points:
form_valid(form)— right after the form passes validation. Set author automatically, generate slug, etc.form_invalid(form)— when validation failsget_success_url()— redirect URL after success (method if dynamic,success_urlattribute if static)get_form_kwargs()— pass extra arguments to the form constructor
The reason for reverse_lazy is that the URL conf may not yet be loaded at class definition time. lazy defers reverse until call time.
FormView — forms without a model
#
from django.views.generic.edit import FormView
from .forms import ContactForm
class ContactView(FormView):
template_name = "blog/contact.html"
form_class = ContactForm
success_url = reverse_lazy("contact_done")
def form_valid(self, form):
form.send_email()
return super().form_valid(form)A good fit for places that need form validation + post-processing without DB persistence.
Mixin — assembling small behaviors #
The real power of CBV is Mixins. You put small behaviors (auth check, permission check, adding context, etc.) in separate classes and assemble them via multiple inheritance.
LoginRequiredMixin — login required
#
from django.contrib.auth.mixins import LoginRequiredMixin
class PostCreateView(LoginRequiredMixin, CreateView):
model = Post
form_class = PostForm
login_url = "/accounts/login/" # where to send unauthenticated users (default settings.LOGIN_URL)
redirect_field_name = "next"LoginRequiredMixin must come first. Multiple-inheritance MRO has to run the check logic before everything else.
PermissionRequiredMixin — permission check
#
from django.contrib.auth.mixins import PermissionRequiredMixin
class PostUpdateView(PermissionRequiredMixin, UpdateView):
model = Post
form_class = PostForm
permission_required = "blog.change_post"
raise_exception = True # without this, 403; with True, raises PermissionDeniedpermission_required can be a string or a list. The permission system itself is covered in detail in #4 Users/permissions.
UserPassesTestMixin — arbitrary condition
#
from django.contrib.auth.mixins import UserPassesTestMixin
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
model = Post
form_class = PostForm
def test_func(self):
post = self.get_object()
return post.author == self.request.userIf test_func returns True, it passes. A clean fit for per-object permissions (only this post’s author can edit).
Writing your own Mixin #
class SidebarContextMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["recent_posts"] = Post.objects.order_by("-created_at")[:5]
ctx["popular_tags"] = Tag.objects.popular()[:10]
return ctx
class PostListView(SidebarContextMixin, ListView):
model = Post
class PostDetailView(SidebarContextMixin, DetailView):
model = PostGather context shared by many views into one place. This is where the real benefit of class inheritance pays off.
Tracing the flow — where does what happen #
The biggest reason CBV feels hard is not knowing how methods flow through the parent classes. Let’s trace ListView’s flow once.
URL → as_view() → dispatch(request)
↓
get(request)
↓
self.object_list = self.get_queryset()
↓
context = self.get_context_data()
↓
self.render_to_response(context)Override points:
| Method | Purpose |
|---|---|
dispatch | Common preprocessing for all methods |
get_queryset | Modify the list queryset (filter, ordering) |
get_context_data | Extra data passed to the template |
get_template_names | Decide template name dynamically |
form_valid/form_invalid | Form processing hook (Create/Update) |
get_success_url | Where to go after success |
If the flow gets confusing, the official Class-based generic views docs page or the Classy CBV site (ccbv.co.uk) is a huge help. All attributes and methods of each class are listed at a glance.
URL registration patterns #
from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path('', views.PostListView.as_view(), name='post_list'),
path('posts/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
path('posts/new/', views.PostCreateView.as_view(), name='post_create'),
path('posts/<slug:slug>/edit/', views.PostUpdateView.as_view(), name='post_update'),
path('posts/<slug:slug>/delete/', views.PostDeleteView.as_view(), name='post_delete'),
]as_view() is a class method — you call it without instantiating the class. A new instance is created on every URL match, so instance state is not shared between requests.
When FBV / when CBV #
| Situation | Recommendation |
|---|---|
| Health check, simple redirect, simple search | FBV |
| Small endpoint that’s just one or two APIs | FBV |
| Standard CRUD (list/detail/create/update/delete) | CBV (Generic) |
| Repeated cross-cutting concerns like auth/login | CBV (Mixin) |
| Complex flow with many branches | FBV (readability) |
| Same pattern across many models | CBV (reuse) |
The Django community’s usual answer: mixing the two is natural. Use CBV where CBV fits, use FBV where FBV fits.
Summary #
What we covered in this post:
- The starting point of CBV —
View+as_view()+ per-method dispatch dispatchis the entry point for every requestTemplateView,RedirectView— the shortest CBVs- Generic CBV —
ListView,DetailView,CreateView,UpdateView,DeleteView,FormView - Key overrides:
get_queryset,get_context_data,form_valid,get_success_url - Mixin —
LoginRequiredMixin,PermissionRequiredMixin,UserPassesTestMixin, your own context Mixin - Mixin inheritance order matters (the one in front runs first)
reverse_lazydefers URL resolution until call time- FBV/CBV is not either-or — mix them as appropriate
In the next post (#2 ORM intermediate), we stack annotate, aggregate, F/Q, select_related/prefetch_related — proper ORM tools — on top of the simple QuerySet from Basics #3. Including the N+1 problem and how to solve it, all in one place.