Django’s Generic Views: FormView
Fourth in my series looking at Django’s generic class-based views and the customisation available in each class is the humble FormView, a simple class that provides a standard pattern for rendering a form, accepting and validating input, surfacing errors, and redirecting on success.
As a reminder, the site Classy Class-Based Views is an invaluable resource for understanding Django’s class-based views.
Posts in this series¶
ListViewDetailViewTemplateViewFormView(this post)
Unlike two of the previous CBVs, the FormView doesn’t operate on a model. Instead, we’re going to start with a simple form:
class PostForm(forms.Form):
title = form.CharField(max_length=255)
is_featured = form.BooleanField(default=False)
post = form.TextField()
We’ll cover the following attributes, if you want to jump straight to each section:
The Simple FormView¶
At its most basic, Django’s FormView will render a template with a particular form. In our case, that’s the PostForm, and it’s specified with the form_class attribute:
from django.views.generic import FormView
from django.urls import reverse_lazy
from .forms import PostForm
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
Besides the form class, a success_url needs to be specified. This can be a raw string URL (such as "/success/"), or a lazily-reversed URL pattern as above.
Additionally, a template_name must be specified too. The FormView does not provide a default like the model-based ListView and DetailView do.
This template will be handed a single context variable named form, which is the bound (for POST requests) or unbound (for GET requests) form instance.
Reminder: an unbound form is one which has not yet been filled in. A bound form is one that has been populated with data, and possibly validated.
On a GET request, the form is passed no data, and the template should render the empty form. On a POST request:
- the form is passed the
POSTdata to create the bound form instance - the form instance is validated
- on a valid form, the user is redirected to
success_url - on an invalid form, the template is rendered with the bound form instance, to display the errors arising from the validation
What’s the point in redirecting immediately? I need to do something with the submitted data!¶
That’s what form_valid is for:
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
def form_valid(self, form):
# Do something with the data in form.cleaned_data
return super().form_valid(form)
The method is passed the form instance. Don’t forget to return, usually calling super(), otherwise no HTTP response will be sent!
I need to redirect somewhere different on success, sometimes.¶
Luckily, success_url doesn’t need to be a static class variable! Override get_success_url:
class PostView(FormView):
form_class = PostForm
template_name = "post_form.html"
def get_success_url(self):
if self.request.user.is_authenticated:
return reverse("user-success")
return reverse("guest-success")
You don’t need both success_url and get_success_url.
I want to do something when bad data is submitted too.¶
Just like form_valid, there’s also form_invalid:
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
def form_invalid(self, form):
logger.warn("Invalid data submitted!")
return super().form_invalid(form)
Also like form_valid, don’t forget to call the super() version of the method (passing the form instance) too.
I want to populate my form with data on first load.¶
Well, as well as from specifying defaults on the form fields themselves, you can always use initial:
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
initial = {
"title": "My Post Title",
}
But my initial data isn’t static!¶
Then override get_initial instead:
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
def get_initial(self):
return {
"title": f"{self.request.user}'s Post Title",
}
My form needs some data from the view before it can render.¶
That’s what get_form_kwargs is for!
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs(self)
kwargs["user"] = self.request.user
return kwargs
Make sure you call super() first, then add to the dictionary of keyword arguments it returns.
My template needs additional context variables.¶
FormView subclasses the same mechanism that powers TemplateView, so check out my previous post to discover how to specify a different template name or add additional variables into the context.
If using get_context_data, however, don’t forget to call super() and add to the returned context, though, otherwise your template will lose its form variable:
class PostView(FormView):
form_class = PostForm
success_url = reverse_lazy("success")
template_name = "post_form.html"
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["post_count"] = Post.objects.count()
return context
Django’s Generic Views: TemplateView
Next in my series on Django’s generic class-based views is the simple TemplateView, a view whose responsibility lies solely in rendering a template.
As a reminder, the site Classy Class-Based Views is an invaluable resource for understanding Django’s class-based views.
Posts in this series¶
ListViewDetailViewTemplateView(this post)FormView
There’s no additional code required for a TemplateView—just a template. We’ll cover the following attributes, if you want to jump straight to each section:
The Simple TemplateView¶
A very simple view, the TemplateView has just one required property:
from django.views.generic import TemplateView
class HomeView(TemplateView):
template_name = "home.html"
The template specified by template_name is rendered by the default template renderer specified in the project’s settings.
My template needs additional context variables.¶
You can use extra_context for that:
class HomeView(TemplateView):
template_name = "home.html"
extra_context = {"section": "blog"}
But the extra data isn’t static, though.¶
For anything more complex, you can override get_context_data:
class HomeView(TemplateView):
template_name = "home.html"
extra_context = {"section": "blog"}
def get_context_data(self, *args, **kwargs):
context = {
"post_count": Post.objects.count(),
}
return context
For this one template, I want to use Jinja2 instead of Django’s template language.¶
Sure! Specify that with template_engine:
class HomeView(TemplateView):
template_name = "home.html"
template_engine = "jinja2"
Inktober 2026
Inktober is a drawing challenge that’s been around for a while now that provides a single-word prompt for each day of October, with the intention to “focus on improving skill and developing positive drawing habits”. This is the second year that I’ve done it, and although I’ve been posting my sketches on Mastodon each day, I’ve brought them all together in this post.















Django’s Generic Views: DetailView
Continuing from my previous post on Django’s generic ListView, this article will instead cover the available customisation of the DetailView. Some options (like ordering and pagination) aren’t available for this view, but there are a few others that are different.
As a reminder, the site Classy Class-Based Views is an invaluable resource for understanding Django’s class-based views.
Posts in this series¶
ListViewDetailView(this post)TemplateViewFormView
Let’s assume a single model:
class Post(models.Model):
title = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True)
is_featured = models.BooleanField(default=False)
title_slug = models.SlugField()
We’ll cover the following attributes, if you want to jump straight to each section:
template_namecontext_object_namequerysetget_queryset()pk_url_kwargslug_fieldslug_url_kwargextra_contextget_context_data()get_object
The Simple DetailView¶
At its most basic, Django’s DetailView will render a template with the details of a particular instance from a specified model. In our case, a single Post object.
from django.views.generic import DetailView
class PostDetail(DetailView):
model = Post
A template named post_detail.html is expected, and it will be handed a variable named object which is a single Post instance. A template variable named post (derived from the model name) is also available, pointing to the same object. The object will be looked up in the Post model by the primary key fetched from the pk URL parameter.
Because the DetailView is designed to look up an object based on the URL, it has to be wired up to a URL pattern with a parameter, such as:
path("/posts/<int:pk>/", PostDetail.as_view(), name="post-detail")
But my template is called single_post.html!¶
Then update the template name that DetailView looks for:
class PostDetail(DetailView):
model = Post
template_name = "single_post.html"
In my template, I use single_post not object!¶
You can change the template variable name that DetailView sets:
class PostDetail(DetailView):
model = Post
template_name = "single_post.html"
context_object_name = "single_post"
Without this, the instance is available as either object or post (the second derived from the model name).
Posts that aren’t published shouldn’t be visible!¶
Specify the base QuerySet that DetailView is using:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
queryset = Post.objects.exclude(published_at__isnull=True)
Now even if you’ve got a URL with a valid primary key in it, the user will see a 404 if it’s not published.
Notice that we don’t need to specify the model anymore, but because we don’t, the default variable of post is no longer available to the template.
Future posts shouldn’t be visible, either.¶
You can’t do that with a class attribute (fetching the current time requires some runtime code, not compile-time code), but the DetailView still has you covered:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now)
Future-dated posts will now return a 404.
My URL parameter isn’t called pk, it’s post_id¶
Change which parameter the view is looking for:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
pk_url_kwarg = "post_id"
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now)
I want to use slugs in the URL, not primary keys.¶
That’s actually supported out of the box, as long as your slug field is called slug and your URL parameter is also called slug:
path("/posts/<slug:slug>/", PostDetail.as_view(), name="post-detail")
My slug field is called title_slug.¶
Oh, your URL pattern is like this, instead?
path("/posts/<slug:title_slug>/", PostDetail.as_view(), name="post-detail")
Tell that to the view:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
slug_field = "title_slug"
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now)
The URL parameter for the slug is also called title_slug.¶
Don’t worry, the view can handle that too:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
slug_field = "title_slug"
slug_url_kwarg = "title_slug"
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now)
I need extra data in the template.¶
Just like the most generic class-based views, the DetailView allow the passing of extra data to the template:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
slug_field = "title_slug"
slug_url_kwarg = "title_slug"
extra_context = {"section": "blog"}
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now)
My extra data isn’t static, though.¶
Again, not limited to the DetailView, adding complex extra data to the template context is easy:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
slug_field = "title_slug"
slug_url_kwarg = "title_slug"
extra_context = {"section": "blog"}
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["featured_posts"] = Post.objects.filter(is_featured=true).order_by(
"-published_at"
)[:5]
return context
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now)
My objects don’t come from models!¶
DetailView can handle that too! Throwing out everything I’ve said above about the Post model and querysets, you can override get_object and return whatever you need:
class PostDetail(DetailView):
template_name = "single_post.html"
context_object_name = "single_post"
def get_object(self, *args, **kwargs):
slug = self.kwargs.get("title_slug")
try:
return fetch_post_from_api(slug=slug)
except APIError:
raise Http404("Post not found")
Django’s Generic Views: ListView
Django’s class-based views are very divisive; some people swear by them, others won’t touch them with a barge pole and will stick entirely to function-based views. I’m not going to weigh in on that debate, but one thing I do think is that Django’s documentation around their generic class-based views is not particularly great. There are no tutorials that show you how to use them, and only reference pages that list some of their properties without any real guidance on what they do.
This post is going to focus on one of the most basic generic class-based views, and one that is reached for first by a lot of new-to-Django developers, the ListView. Starting from the simplest possible use, we’ll walk through various options on how to customise its behaviour.
An excellent resource for diving deep into Django’s class-based views is Classy Class-Based Views, which I highly recommend having a look through when you’re ready to start piecing together how class-based views work under the hood.
Posts in this series¶
ListView(this post)DetailViewTemplateViewFormView
For the purposes of this post, we’re going to assume a single model:
class Post(models.Model):
title = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True)
is_featured = models.BooleanField(default=False)
We’ll cover the following attributes, if you want to jump straight to each section:
template_namecontext_object_nameorderingquerysetget_queryset()paginate_byextra_contextget_context_data()
The Simple ListView¶
At its most basic, Django’s ListView will render a template with a list of instances of a particular model. Just two lines (beyond the import) is enough to get this functionality:
from django.views.generic import ListView
class PostList(ListView):
model = Post
A template named post_list.html is expected, and it will be handed a variable named object_list which is a QuerySet of all Post instances.
But my template is called all_posts.html!¶
Then update the template name that ListView looks for:
class PostList(ListView):
model = Post
template_name = "all_posts.html"
In my template, I use posts not object_list!¶
You can change the template variable name that ListView sets:
class PostList(ListView):
model = Post
template_name = "all_posts.html"
context_object_name = "posts"
Without this, the list is available as either object_list or post_list (the second derived from the model name).
I need the posts to appear with the most recent first!¶
Define what order is used by the QuerySet:
class PostList(ListView):
model = Post
template_name = "all_posts.html"
context_object_name = "posts"
ordering = "-published_at"
Posts that aren’t published yet are still appearing!¶
Specify the base QuerySet that ListView is using:
class PostList(ListView):
template_name = "all_posts.html"
context_object_name = "posts"
ordering = "-published_at"
queryset = Post.objects.exclude(published_at__isnull=True)
Notice that we don’t need to specify the model anymore.
What if I want to also exclude posts that have been set to publish in the future?¶
You can’t do that with a class attribute (fetching the current time requires some runtime code, not compile-time code), but the ListView still has you covered:
class PostList(ListView):
template_name = "all_posts.html"
context_object_name = "posts"
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now).order_by("-published_at")
Note that providing a QuerySet this way will ignore both the queryset and ordering attributes mentioned above, which is why we’ve included the ordering directly in the get_queryset method now. You can call super().get_queryset() if you want to use those attributes and just adjust the query set after, though.
Too many posts are showing up! I only want ten.¶
Add pagination:
class PostList(ListView):
template_name = "all_posts.html"
context_object_name = "posts"
paginate_by = 10
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now).order_by("-published_at")
The Pagination docs are a great place to see what’s required in the template.
I need extra data in the template.¶
This is not limited to the ListView, but most generic class-based views allow the passing of extra data to the template:
class PostList(ListView):
template_name = "all_posts.html"
context_object_name = "posts"
paginate_by = 10
extra_context = {"section": "blog"}
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now).order_by("-published_at")
My extra data isn’t static, though.¶
Also not limited to the ListView, adding complex extra data to the template context is easy:
class PostList(ListView):
template_name = "all_posts.html"
context_object_name = "posts"
paginate_by = 10
extra_context = {"section": "blog"}
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["featured_posts"] = Post.objects.filter(is_featured=true).order_by(
"-published_at"
)[:5]
return context
def get_queryset(self):
now = timezone.now()
return Post.objects.filter(published_at__lte=now).order_by("-published_at")
This may become a series of posts working through the different generic class-based views that Django provides, but I will make no promises! For now, this should suffice as a quick reference for those wanting to easily customise the behaviour of ListView without reinventing the wheel.
Pendulum 1.18 Released
A new version of Pendulum is out—version 1.18 is now available in the App Store! This brings with it one main feature: custom stationery.

Multiple people over the last couple of years have asked for a way to track different data points about each letter they send, beyond just the pen, ink, and paper they used. The request has come in a variety of forms—some would like to track stamps, others washi tape, and others the envelope they used.
Rather than provide a limited set of options, I’ve added the ability for you to add your own categories of stationery to track, and to give each one an icon. The possibilities are endless; I’m going to be using mine to track the wax seal I used, for example. I’d love to know what others end up using it for!

Legami Friends Reference
At the airport on our way to Lisbon last year, my daughter came across a Legami gel ink pen with a little plastic unicorn clipped to the barrel, and immediately bought it. We knew of the brand, and seen other products of theirs before, but not this particular style.
A few days later, in the oldest bookstore in the world, we found a whole stand of other variants—teddies, llamas, even a panda; which she of course had to buy, as her favourite animal. From there, the collection began.
We started trying to find all the different animals we could. The ink varies in colour from pen to pen, and each animal has a little slogan on the barrel. Legami call it their Lovely Friends range, and I wanted them all!
Every time I thought we’d gotten all there were, I’d come across a one off in an independent stationery store or at the back of a corner shop, so I finally decided to actually do some research and figure out what the entire collection was.
Inspired by Robb Knight’s Mildliner’s reference, the end result of this is a new micro site I’m calling the Legami Friends reference. It includes all Lovely Friends pens (21 at the time of writing), along with their ink colour, pun-filled slogans, and details on any multipacks they’re available in or the occasion they were released for. My research led me to also include the erasable versions that feature the same Friends characters—there are a whopping 64, and many older out-of-production ones are listed for ridiculous prices on eBay.
The site lets you filter by pen type, ink colour, occasion, multipack, or character. I will endeavour to keep it up to date as new pens are released and older ones retired, and whilst I think it’s as complete as I can get it, I am sure there are some details missing. Let me know if you come across any out there!
Brickset LEGO Gift Guide - 2025
It’s that time of the year again (where on earth has 2025 gone?!) where Brickset publish their annual holiday gift guide, in five parts split by price category. Along with the other Brickset contributors, I provided my opinion, and there’s a nice diverse range of sets across the price ranges! You can view each of the articles below, along with my pick from the available choices:
Under $25¶
- 10349 Happy Plants
$25-$50¶
- 31165 Panda Family
$50-$100¶
- 72046 Game Boy
$100-$200¶
$200+¶
- 76457 Hogsmeade Village - Collectors’ Edition
- honorary mention of 76968 Dinosaur Fossils: Tyrannosaurus rex
What would your choices be?

Pendulum 1.15 Released
After quite some time without any updates, I have finally released a new version of Pendulum—version 1.15 is now available in the App Store!

With the release of iOS 26 and it’s new Liquid Glass design language, I wanted to update Pendulum to feel more at home on the OS, and took the opportunity to change the design somewhat more drastically—at least for the main Pen Pals list. The biggest structural change was to drop the tab bar—it makes little sense when there are only two tabs—and move Settings to a top bar button. This meant I could take advantage of the new navigation transition available in iOS 26 to morph between the button and the presented sheet in a very pleasing manner.
Visually, the Pen Pal list has had a huge overhaul, with the standout change being the map in the background. This will centre itself on the location you most recently sent a letter to or received one from, and I love the way it turned out. I hope it’ll be fun for Pendulum’s users to see it pan around the world as they send and receiving their correspondence. A further update may pull the map out into its own feature, with pins for your pen pals’ locations in a more interactive manner.
Given the design now focuses rather heavily on your pen pals’ addresses, it was about time I addressed (pun intended) the inability for you to store an address against a pen pal if you’ve disabled syncing with Contacts. It’s a fairly commonly-requested feature, and this was the impetus I needed to finally get it done. Both types of Pen Pals can live happily together in Pendulum—those synced with a device contact will require you to use Contacts to update their address, and those added manually can be edited directly within the app.

The final feature in this release can be seen in the screenshot above, in Amelia’s contact details page, Pendulum will pull in any nicknames you have against your linked Contacts, with an option to prefer nicknames over full names in most of Pendulum’s UI. Amelia will be shown as Millie in the Pen Pal list and her correspondence screen, for example.
It’s been fun to have the motivation and impetus to work on Pendulum again, and I’m excited to keep adding features and iterating as I go.
Django Forms and CSV Processing
Recently, I’ve found myself building a number of tools that accept input data in the form of a CSV from the user, and parse and validate it before executing whatever processing is necessary for the given tool. Django’s forms provide an easy way to accept file uploads, and model forms make it trivial to store those files on disk alongside a model instance.
What I was struggling with was where to do the validation of the contents of the CSV. Forms provide some level of validation, and give the user feedback via field- or form-level errors, but in order to provide useful feedback via this mechanism the form needs to parse the CSV to validate each of its rows. This is easily doable in the field-specific clean method, such as the following, which validates that every row has an identifier field and a date field, with the date in the future (using arrow for date parsing):
import csv
import arrow
from django import forms
from django.db import models
from django.utils import timezone
class CSVUpload(models.Model):
uploaded_at = models.DateTimeField(auto_now_add=True)
csv_file = models.FileField(upload_to="csv_uploads")
class CSVUploadForm(forms.ModelForm):
class Meta:
model = CSVUpload
fields = ["csv_file"]
def clean_csv_file(self):
csv_file = self.cleaned_data["csv_file"]
try:
for row in csv.DictReader(csv_file):
if not row.get("identifier", "").strip():
raise ValueError("Missing identifier")
if arrow.get("date") <= timezone.now():
raise ValueError("Date not in the future")
except ValueError as err:
raise forms.ValidationError(f"Could not read CSV file; please ensure it is in the correct format ({err})")
return csv_file
This works fine—the form won’t pass the is_valid() check unless the data within the CSV is valid. So what’s the problem?
Well, there’s a reason we’re reading the CSV and validating the data—we want to do something with the data. The form has parsed it, but it’s thrown away any results of that parsing, leaving the view to have to do it all over again, which is less than ideal. We can’t simply return the parsed data from the clean method, because that would break Django’s FileField handling within the model form. Instead, we can take advantage of the fact that a Form instance is just that—a standard Python object, nothing special—and set an attribute on the instance with the parsed data:
class CSVUploadForm(forms.ModelForm):
class Meta:
model = CSVUpload
fields = ["csv_file"]
def clean_csv_file(self):
csv_file = self.cleaned_data["csv_file"]
parsed_data = []
try:
for row in csv.DictReader(csv_file):
row_data = {
"identifier": row.get("identifier", "").strip(),
"date": arrow.get("date"),
}
if not row_data["identifier"]:
raise ValueError("Missing identifier")
if row_data["date"] <= timezone.now():
raise ValueError("Date not in the future")
parsed_data.append(row_data)
except ValueError as err:
raise forms.ValidationError(f"Could not read CSV file; please ensure it is in the correct format ({err})")
else:
self.parsed_data = parsed_data
return csv_file
Now, within the view, we can let Django handle the model form as it should, and read the form’s parsed_data attribute to use the data from within the CSV as necessary:
class CSVUploadView(FormView):
form_class = CSVUploadForm
def is_valid(self, form):
instance = form.save()
for data in form.parsed_data:
# Use the data from the CSV, already parsed
pass
return HttpResponseRedirect(self.get_success_url())
Simple!