Forms & ModelForms
In Forms & Request Data you validated an order form by hand: read each value, convert it, check it, collect errors, re-show the form. Django's forms library does all of that for you. You describe the fields once in a class, and Django renders the HTML, converts the submitted strings into Python values, validates them, and remembers the errors and the user's input for redisplay.
In this lesson you will build a "Suggest a book" form for the Jaipur City Library, add custom validation, write the standard view pattern, protect it with a CSRF token, and then use a ModelForm to add and edit Book rows with almost no code.
A Form class
# catalog/forms.py
from datetime import date
from django import forms
from .models import Book
class SuggestionForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
title = forms.CharField(label="Book title", max_length=200)
year = forms.IntegerField(min_value=1800, required=False)
reason = forms.CharField(widget=forms.Textarea(attrs={"rows": 3}), max_length=500)
def clean_year(self):
year = self.cleaned_data["year"]
if year and year > date.today().year:
raise forms.ValidationError("That year is in the future.")
return year
def clean(self):
cleaned = super().clean()
title = cleaned.get("title", "")
if title and Book.objects.filter(title__iexact=title).exists():
self.add_error("title", "We already have this book.")
return cleanedEach field knows how to convert and check its value. EmailField checks the format, IntegerField converts to int and checks min_value, and every field is required unless you write required=False. The widget decides the HTML: Textarea instead of a single-line input.
Validation runs in a fixed order, and you can hook into it:
- Each field's own checks (type, length, required).
clean_<fieldname>()methods for extra checks on one field. Return the cleaned value.clean()for checks that involve several fields or the database. Useself.add_error(field, message)to attach an error to a field.
Try it in the shell:
form = SuggestionForm(data={"name": "Rohan", "email": "rohan@", "title": "Godaan",
"year": "2031", "reason": ""})
print(form.is_valid())
for field, errors in form.errors.items():
print(field, "->", errors[0])
form = SuggestionForm(data={"name": "Rohan", "email": "rohan@example.com",
"title": "Pinjar", "year": "1950", "reason": "Classic Punjabi novel"})
print(form.is_valid(), form.cleaned_data["year"], type(form.cleaned_data["year"]).__name__)False email -> Enter a valid email address. year -> That year is in the future. reason -> This field is required. title -> We already have this book. True 1950 int
is_valid() runs everything and returns True only when there are no errors. After that, read values from form.cleaned_data, never from request.POST: cleaned values are converted ("1950" became the integer 1950) and checked.
The view pattern
Almost every Django form view has this shape:
# catalog/views_forms.py
from django.contrib import messages
from django.shortcuts import redirect, render
from .forms import SuggestionForm
def suggest(request):
if request.method == "POST":
form = SuggestionForm(request.POST)
if form.is_valid():
data = form.cleaned_data
# In a real app: save it, or email the librarian.
messages.success(request, f"Thanks {data['name']}, we will look for {data['title']}.")
return redirect("catalog:suggest")
else:
form = SuggestionForm()
return render(request, "catalog/suggest.html", {"form": form})On GET, show an empty form. On POST, fill the form with request.POST; if it is valid, act and redirect (Post/Redirect/Get); if not, fall through and render the same form, which now carries the errors and the user's input. The messages framework (django.contrib.messages) works like Flask's flash(): a message is stored now and shown once on the next page.
Rendering the form and CSRF
<!-- catalog/templates/catalog/suggest.html -->
{% extends "base.html" %}
{% block content %}
{% for message in messages %}<p class="{{ message.tags }}">{{ message }}</p>{% endfor %}
<h1>Suggest a book</h1>
<form method="post">
{% csrf_token %}
{{ form }}
<button type="submit">Send suggestion</button>
</form>
{% endblock %}{{ form }} renders every field with its label, errors and current value, wrapped in <div> elements. For more control, render one field at a time with {{ form.email.as_field_group }} (Django 5.0+). After an invalid submit, that field group looks like this:
<label for="id_email">Email:</label> <ul class="errorlist" id="id_email_error"><li>Enter a valid email address.</li></ul> <input type="email" name="email" value="rohan@" maxlength="320" required aria-invalid="true" aria-describedby="id_email_error" id="id_email">
Django even adds aria-invalid and aria-describedby, so screen readers announce the error. The user's input rohan@ is kept in the box.
{% csrf_token %} inserts a hidden field with a secret token. Django's CsrfViewMiddleware rejects any POST without a valid token with 403 Forbidden. Posting the form from a test client that enforces CSRF checks, without the token, gives exactly that status: 403. This blocks other websites from submitting forms on behalf of your logged-in users; Security explains the attack.
ModelForm: forms from models
Most forms create or edit a model, and repeating every field would be boring and error-prone. A ModelForm builds its fields from the model, including validation such as max_length, unique and choices:
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "slug", "author", "language", "year", "price", "copies", "genres"]
widgets = {"genres": forms.CheckboxSelectMultiple}
def clean_slug(self):
return self.cleaned_data["slug"].lower()
def clean_price(self):
price = self.cleaned_data["price"]
if price <= 0:
raise forms.ValidationError("Price must be more than zero.")
return priceOne view handles both adding and editing. instance=book fills the form with an existing row; form.save() inserts or updates and returns the object:
def book_edit(request, slug=None):
book = get_object_or_404(Book, slug=slug) if slug else None
form = BookForm(request.POST or None, instance=book)
if request.method == "POST" and form.is_valid():
book = form.save()
messages.success(request, f"Saved {book.title}.")
return redirect(book)
return render(request, "catalog/book_form.html", {"form": form, "book": book})Posting a new book with slug PINJAR and the author Amrita Pritam redirects to /books/pinjar/ (the slug was lower-cased by clean_slug(), and redirect(book) used get_absolute_url()). Editing it with a price of -5 re-shows the form with Price must be more than zero.
Always list fields explicitly. If you used fields = "__all__", adding a field such as is_staff_pick to the model later would silently let any user set it through the form. When you need to set values the user must not control, save with commit=False:
book = form.save(commit=False)
book.available = book.copies # set by code, not by the user
book.save()
form.save_m2m() # needed for many-to-many fields after commit=FalseCommon mistakes
- Forgetting
{% csrf_token %}. Every POST fails with403 Forbidden (CSRF token missing.). - Reading
request.POSTafter validation. Useform.cleaned_data, which is converted and checked. - Rendering only some fields. Errors on fields you did not render stay invisible, and the form seems to "do nothing". Render
{{ form.non_field_errors }}and every field. fields = "__all__"in a ModelForm. Users can set any field you add later; list fields explicitly.- Not redirecting after success. Refreshing then submits the form again.
Key points
- A
forms.Formclass declares fields;is_valid()converts and validates, andcleaned_dataholds the results. - Add checks with
clean_<field>()for one field andclean()withadd_error()for several fields. - The view pattern is: GET shows an empty form; POST validates, then redirects on success or re-renders with errors.
{% csrf_token %}is required in every POST form;{{ form }}andas_field_grouprender accessible HTML.ModelFormbuilds fields from a model andsave()creates or updates rows; usecommit=Falseto set extra values.
Exercise
Create a MemberForm ModelForm for the Member model with the fields name, phone and city. Add clean_phone() that removes spaces and checks that the result is exactly 10 digits starting with 6, 7, 8 or 9 (Indian mobile numbers), and clean_name() that turns the name into title case. Test it in the shell with a valid and an invalid phone number.
Show solution
class MemberForm(forms.ModelForm):
phone = forms.CharField(max_length=15) # allow spaces in what people type
class Meta:
model = Member
fields = ["name", "phone", "city"]
def clean_phone(self):
phone = self.cleaned_data["phone"].replace(" ", "")
if not (len(phone) == 10 and phone.isdigit() and phone[0] in "6789"):
raise forms.ValidationError("Enter a 10-digit Indian mobile number.")
return phone
def clean_name(self):
return self.cleaned_data["name"].title()
form = MemberForm(data={"name": "meera joshi", "phone": "98290 12345", "city": "Jaipur"})
print(form.is_valid(), form.cleaned_data["name"], form.cleaned_data["phone"])
form = MemberForm(data={"name": "Farhan", "phone": "12345", "city": "Ajmer"})
print(form.is_valid(), form.errors["phone"][0])True Meera Joshi 9829012345 False Enter a 10-digit Indian mobile number.
The model's max_length=10 would reject "98290 12345" (11 characters) before clean_phone() even runs, so the form declares its own phone field with room for spaces. clean_phone() then removes the spaces, and the 10-digit result fits the model.
Quick Quiz
1. After form.is_valid() returns True, where should you read the values?
cleaned_data holds converted, validated values.
2. Which method checks two fields together, such as title and author?
clean() runs after the per-field checks and can see all cleaned values.
3. A POST form has no {% csrf_token %}. What does Django return?
CsrfViewMiddleware rejects POST requests without a valid token.
4. Why use form.save(commit=False)?
It returns an unsaved instance; call save() and then save_m2m() yourself.
Learn Django & Flask with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
