🛡️ Form Validation and Processing
Rendering a form is the easy half. The hard, valuable half is making sure the data that comes back is correct — well-formed, consistent, and safe to trust. Django validates in layers, and once you know where each layer lives, enforcing any business rule becomes a matter of picking the right hook.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe the three levels of validation — field, form, and model — and the order they run in
- Attach reusable validators and use built-in ones from
django.core.validators - Write
clean_<field>()methods for single-field rules and overrideclean()for cross-field rules - Distinguish field errors from non-field errors and display both in a template
- Use a formset to validate and process many copies of one form at once
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Add layered validation to a sign-up form, including a cross-field password check.
In This Lesson
Why Validate in Layers
Validation protects three things at once: data integrity (no garbage in your database), user experience (clear, specific error messages), and security (untrusted input never reaches sensitive code unchecked). The golden rule: never trust the client. Browser attributes like required and maxlength are helpful hints, but they can be removed with a click of the dev tools — server-side validation is the only real gate.
💡 Analogy — the quality-control line. Before a product ships, it passes through inspection stations: one checks individual components (field validation), one checks that the components fit together (form validation), and a final station checks the assembled product against spec (model validation). Each station catches a different class of defect, and together they let only sound products through.
The Validation Pipeline
When you call form.is_valid(), Django runs a fixed sequence. Understanding the order tells you exactly where to put any given rule — and why a value might be missing from cleaned_data when a later step runs.
Two consequences worth memorizing:
- If a field fails its own validation, it never reaches
cleaned_data— so inclean()you must usecleaned_data.get("x")(which may returnNone), notcleaned_data["x"]. - Each
clean_<field>()runs after that field's basic validation, so you can assume the value is already the correct type.
Field-Level Validation
Every field type carries built-in checks: EmailField enforces email shape, IntegerField rejects non-integers, URLField checks URL format. You tune them with parameters, and you can override the messages.
# forms.py
from django import forms
class ProductForm(forms.Form):
name = forms.CharField(
min_length=3, max_length=100,
error_messages={
"required": "Please enter a product name.",
"min_length": "Name must be at least 3 characters.",
},
)
price = forms.DecimalField(min_value=0.01, max_digits=8, decimal_places=2)
quantity = forms.IntegerField(min_value=1)
category = forms.ChoiceField(choices=[
("electronics", "Electronics"),
("clothing", "Clothing"),
("books", "Books"),
])
Reusable validators
A validator is any callable that raises ValidationError on bad input. Because it's decoupled from the form, one validator can be attached to many fields — even to a model field. Django ships plenty (MinValueValidator, RegexValidator, MaxLengthValidator), and writing your own is trivial.
# validators.py
from django.core.exceptions import ValidationError
def validate_even(value):
if value % 2 != 0:
raise ValidationError(
"%(value)s is not an even number.",
params={"value": value},
)
# forms.py
from django import forms
from django.core.validators import MinValueValidator
from .validators import validate_even
class EventForm(forms.Form):
name = forms.CharField(max_length=100)
num_tables = forms.IntegerField(
validators=[validate_even, MinValueValidator(2)],
)
📖 Validator vs. clean method
Reach for a validator when the rule is reusable and depends only on that one value (an even number, a phone-number pattern). Reach for a clean_<field>() method when the rule is specific to this form or needs database access (is this username taken?).
clean_<field> Methods
To validate a single field with logic tied to this form — including hitting the database — add a method named clean_<fieldname>. It must read the value from cleaned_data, check it, and return the (possibly transformed) value.
# forms.py
from django import forms
from django.contrib.auth.models import User
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
email = forms.EmailField()
password1 = forms.CharField(widget=forms.PasswordInput)
password2 = forms.CharField(
widget=forms.PasswordInput, label="Confirm password",
)
def clean_username(self):
username = self.cleaned_data["username"]
if not username.isalnum():
raise ValidationError("Username may contain only letters and numbers.")
if User.objects.filter(username__iexact=username).exists():
raise ValidationError("That username is already taken.")
return username
def clean_email(self):
email = self.cleaned_data["email"]
if User.objects.filter(email__iexact=email).exists():
raise ValidationError("An account with this email already exists.")
return email
⚠️ Always return the value
If you forget the return at the end of a clean_<field>() method, Django stores None for that field — the value silently vanishes even though validation "passed." It's the single most common form bug.
Form-Level Validation
Some rules span multiple fields: "the two passwords must match," "the return date must be after the departure date." These belong in the form-wide clean() method, which sees every field's cleaned value at once.
from django import forms
from django.core.exceptions import ValidationError
class PasswordChangeForm(forms.Form):
old_password = forms.CharField(widget=forms.PasswordInput)
new_password1 = forms.CharField(widget=forms.PasswordInput, label="New password")
new_password2 = forms.CharField(widget=forms.PasswordInput, label="Confirm")
def clean(self):
cleaned = super().clean()
old = cleaned.get("old_password")
new1 = cleaned.get("new_password1")
new2 = cleaned.get("new_password2")
# Attach an error to a specific field:
if new1 and new2 and new1 != new2:
self.add_error("new_password2", "The two passwords don't match.")
# Raise for a form-wide (non-field) error:
if old and new1 and old == new1:
raise ValidationError("New password must differ from the old one.")
return cleaned
💡 add_error vs. raise ValidationError
Use self.add_error("field", msg) to pin the error next to a specific input. Use raise ValidationError(msg) inside clean() for a rule that isn't about one field — it becomes a non-field error shown at the top of the form. Note the .get() calls: a field that failed earlier won't be in cleaned_data.
Model-Level Validation
When you use a ModelForm, a third layer joins in: the model's own validators and its clean() method. This is powerful because the rule lives with the data, so it protects every path that saves the model — the admin, a script, a management command — not just this one form.
# models.py
import datetime
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
def validate_not_past(value):
if value < datetime.date.today():
raise ValidationError("Date cannot be in the past.")
class Event(models.Model):
title = models.CharField(max_length=200)
date = models.DateField(validators=[validate_not_past])
max_attendees = models.IntegerField(
validators=[MinValueValidator(5), MaxValueValidator(1000)],
)
def clean(self):
super().clean()
# A model-wide rule: cap events per day.
same_day = Event.objects.filter(date=self.date)
if self.pk: # exclude self when editing
same_day = same_day.exclude(pk=self.pk)
if same_day.count() >= 3:
raise ValidationError({"date": "No more than 3 events per day."})
# forms.py
from django import forms
from .models import Event
class EventForm(forms.ModelForm):
class Meta:
model = Event
fields = ["title", "date", "max_attendees"]
✅ ModelForm runs model validation for you
Calling form.is_valid() on a ModelForm triggers the model's field validators and its clean() as part of the same pass, so the errors surface in the same place as your form errors. (Direct Model.objects.create() calls skip this — model clean() only runs via full_clean(), which ModelForm and the admin call automatically.)
Displaying Errors
Errors come in two flavors and you render them in two places: field errors next to their input via {{ field.errors }}, and non-field errors at the top via {{ form.non_field_errors }}.
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% for field in form %}
<div class="form-group">
{{ field.label_tag }}
{{ field }}
{% if field.errors %}
<div class="field-error">
{% for error in field.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
{% if field.help_text %}
<small class="help">{{ field.help_text }}</small>
{% endif %}
</div>
{% endfor %}
<button type="submit">Save</button>
</form>
Formsets: Many Forms at Once
Sometimes one page needs several copies of the same form — registering a group of attendees, adding multiple line items to an order. A formset wraps N forms and validates them together.
# views.py
from django.forms import formset_factory
from django.shortcuts import render, redirect
from .forms import AttendeeForm
from .models import Event
def register_group(request, event_id):
event = Event.objects.get(pk=event_id)
AttendeeFormSet = formset_factory(AttendeeForm, extra=3)
if request.method == "POST":
formset = AttendeeFormSet(request.POST)
if formset.is_valid():
for form in formset:
if form.has_changed(): # skip untouched blank rows
attendee = form.save(commit=False)
attendee.event = event
attendee.save()
return redirect("registration_success")
else:
formset = AttendeeFormSet()
return render(request, "group_register.html",
{"formset": formset, "event": event})
<form method="post">
{% csrf_token %}
{{ formset.management_form }} {# required — tracks form count #}
{% for form in formset %}
<fieldset>{{ form.as_div }}</fieldset>
{% endfor %}
<button type="submit">Register group</button>
</form>
⚠️ Don't drop the management form
{{ formset.management_form }} renders the hidden fields that tell Django how many forms were sent. Omit it and the formset raises a ManagementForm data is missing error on submit.
Hands-on Exercise
🏋️ Layer validation onto a sign-up form
Objective: Enforce one field-level, one clean_<field>, and one cross-field rule on a single form.
Instructions
- Start from a
SignupFormwithusername,email,password1, andpassword2(both PasswordInput). - Add a
clean_username()that rejects a username shorter than 4 characters or already present inUser. - Override
clean()to require thatpassword1andpassword2match, attaching the error topassword2. - Also in
clean(), raise a non-fieldValidationErrorif the password contains the username.
💡 Hint
In clean() use .get(), not subscripting — if clean_username raised, username won't be in cleaned_data. Use self.add_error("password2", ...) for the mismatch and a bare raise ValidationError(...) for the form-wide rule.
✅ Solution
from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
email = forms.EmailField()
password1 = forms.CharField(widget=forms.PasswordInput)
password2 = forms.CharField(widget=forms.PasswordInput, label="Confirm")
def clean_username(self):
username = self.cleaned_data["username"]
if len(username) < 4:
raise ValidationError("Username must be at least 4 characters.")
if User.objects.filter(username__iexact=username).exists():
raise ValidationError("That username is already taken.")
return username
def clean(self):
cleaned = super().clean()
username = cleaned.get("username")
p1 = cleaned.get("password1")
p2 = cleaned.get("password2")
if p1 and p2 and p1 != p2:
self.add_error("password2", "Passwords don't match.")
if username and p1 and username.lower() in p1.lower():
raise ValidationError("Password must not contain your username.")
return cleaned
Three layers, three techniques: a length check and DB check in clean_username, a field-targeted mismatch error, and a form-wide security rule.
Best Practices
✅ Do
- Put single-field, reusable rules in validators; form-specific single-field rules in
clean_<field>(); cross-field rules inclean(). - Push data-integrity rules down to the model so every save path is protected.
- Always
returnthe value from aclean_<field>()method. - Write specific, actionable error messages the user can act on.
❌ Don't
- Don't rely on HTML/JavaScript validation for correctness or security — it's a convenience, not a guarantee.
- Don't subscript
cleaned_data["x"]insideclean(); a failed field won't be there. - Don't duplicate the same rule in three layers — pick the layer that fits and keep it single-sourced.
- Don't forget
{{ formset.management_form }}when rendering a formset.
Summary & Quiz
🎉 Key Takeaways
- Django validates in three layers — field → form → model — running in that order inside
is_valid(). - Validators are reusable callables;
clean_<field>()handles form-specific single-field rules;clean()handles cross-field rules. - Use
self.add_error(field, msg)for a targeted error andraise ValidationErrorinclean()for a non-field error. - Model-level validation protects every save path, not just one form.
- Formsets validate and process many copies of a form together — remember the management form.
🎯 Quick Quiz
Question 1: Where do you put a rule that compares two fields, like "return date must be after departure date"?
Question 2: Inside clean(), why should you use cleaned_data.get("email") instead of cleaned_data["email"]?
Question 3: A clean_username() method validates fine but the username keeps saving as None. What's the likely cause?
📚 Further Reading
🚀 What's Next?
Your data is now clean and trustworthy. Next we turn to Django's free management interface — the admin — and learn to customize it into a genuine back-office tool.
🎉 Solid work!
You can now enforce any business rule at the right layer. On to the admin.