PYTHON / DJANGO
Forms and validation
Define Django Form classes, validate untrusted request data through the field/clean_<field>/clean pipeline, and read typed values from cleaned_data.
What you will learn
- Declare a Form class whose fields coerce raw POST strings into typed Python values
- Add per-field rules in clean_<field>() and always return the cleaned value
- Compare two fields in clean(), attaching the error with add_error(field, msg)
- Read form.errors and form.cleaned_data only after calling is_valid()
Understanding Forms and validation
A Django Form is a declarative description of what a chunk of untrusted input is allowed to be. When you instantiate it with data, such as SignupForm(request.POST), the form is bound: it holds the raw strings the browser sent. Nothing has been checked yet. Calling is_valid() runs the whole validation machinery and returns a boolean; only then does form.cleaned_data exist, holding values converted to real Python types such as int, date, or Decimal. An unbound form, SignupForm(), has no data at all, so is_valid() on it is always False.
Validation runs in a fixed order, and knowing that order explains almost every surprise. For each field, Django calls to_python() to convert the string, then validate() for emptiness and type rules, then any functions in the field's validators list. If the field survives, Django calls your clean_<fieldname>() hook, whose return value becomes the entry in cleaned_data. Fields are processed in declaration order, and a field that fails is deleted from cleaned_data, which is why cross-field code must use cleaned_data.get() rather than square brackets.
After every field has been handled, Django calls the form's clean() method once. This is the only place where you can see all the values together, so it is where rules like "password must equal confirm_password" or "end date must be after start date" belong. Raising ValidationError inside clean() produces a non-field error, reachable through form.non_field_errors() and rendered at the top of the form; calling self.add_error("confirm_password", msg) instead attaches the message next to that specific input, which is almost always what a user needs.
import django
from django.conf import settings
settings.configure(USE_I18N=False)
django.setup()
from django import forms
class SignupForm(forms.Form):
username = forms.CharField(max_length=20)
age = forms.IntegerField(min_value=13)
password = forms.CharField()
confirm_password = forms.CharField()
def clean_username(self):
name = self.cleaned_data["username"].strip()
if not name.isalnum():
raise forms.ValidationError("Use letters and digits only.")
return name.lower()
def clean(self):
data = super().clean()
if data.get("password") and data.get("confirm_password"):
if data["password"] != data["confirm_password"]:
self.add_error("confirm_password", "The two passwords do not match.")
return data
bad = SignupForm({"username": "Ada Lovelace", "age": "11",
"password": "hunter2", "confirm_password": "hunter3"})
print("valid:", bad.is_valid())
for name, messages in bad.errors.items():
print(" ", name, list(messages))
good = SignupForm({"username": " AdaL ", "age": "42",
"password": "hunter2", "confirm_password": "hunter2"})
print("valid:", good.is_valid())
print("cleaned:", good.cleaned_data["username"], type(good.cleaned_data["age"]).__name__)A form is a validation pipeline that turns raw request strings into typed values in cleaned_data, with per-field hooks running before the whole-form clean().
Worked examples
Reusable validator and non-field errors
Shows a validator function attached to a field, and a clean() error that belongs to no single field.
import django
from django.conf import settings
settings.configure(USE_I18N=False)
django.setup()
from django import forms
from django.core.exceptions import ValidationError
def no_shouting(value):
if value.isupper():
raise ValidationError("%(value)s is all caps.", params={"value": value})
class BookingForm(forms.Form):
title = forms.CharField(validators=[no_shouting])
seats = forms.IntegerField()
seats_available = forms.IntegerField()
def clean(self):
data = super().clean()
if data.get("seats") and data.get("seats_available") is not None:
if data["seats"] > data["seats_available"]:
raise ValidationError("Not enough seats left.")
return data
form = BookingForm({"title": "URGENT MEETING", "seats": "5", "seats_available": "2"})
print(form.is_valid())
print(list(form.errors["title"]))
print(list(form.non_field_errors()))
print("title" in form.cleaned_data, "seats" in form.cleaned_data)Example explained
Line 1validators=[no_shouting] runs the plain function after the field's own type and length checks pass.
Line 2params={"value": value} is interpolated into the message string when the error is rendered.
Line 3ValidationError raised in clean() lands in non_field_errors(), not next to any input.
Line 4title was removed from cleaned_data because it failed, while the valid seats value stayed.
A clean_ hook that forgets to return
Demonstrates that the return value of clean_<field> replaces the entry in cleaned_data.
import django
from django.conf import settings
settings.configure(USE_I18N=False)
django.setup()
from django import forms
class EmailForm(forms.Form):
email = forms.EmailField()
backup = forms.EmailField()
def clean_email(self):
return self.cleaned_data["email"].lower()
def clean_backup(self):
self.cleaned_data["backup"].lower()
form = EmailForm({"email": "ADA@Example.COM", "backup": "GRACE@Example.COM"})
print(form.is_valid())
print(repr(form.cleaned_data["email"]))
print(repr(form.cleaned_data["backup"]))Example explained
Line 1clean_email returns the lowercased string, so cleaned_data["email"] is replaced with it.
Line 2clean_backup computes the same string but discards it, so the method returns None.
Line 3Django stores that None in cleaned_data["backup"]; the form is still valid, so nothing warns you.
Line 4The lost value only surfaces later, usually as a NULL row or a blank field in the database.
Important notes
The first positional argument to a Form is the data, so MyForm({...}) is bound and MyForm(initial={...}) is not; passing initial where you meant data makes is_valid() return False with no errors shown.
A CharField with required=False produces "" rather than None by default, so test with `if not value` rather than `if value is None` in your clean hooks.
Common mistakes
Writing a clean_<field>() method with no return statement: the form still validates but cleaned_data[field] silently becomes None, so the value is lost on save.
Using self.cleaned_data["password"] inside clean(): if that field already failed validation its key was deleted, so you get a KeyError that turns into a 500 error instead of a form error page.
Reading request.POST["age"] instead of form.cleaned_data["age"] after validating: you get the raw string "42", not an int, and you bypass every rule the form just enforced.
Try it yourself
Change, predict, then run
Write an EventForm with start_hour and end_hour IntegerFields limited to 0-23, and a clean() that calls add_error("end_hour", ...) when end_hour is not greater than start_hour. Bind it to {"start_hour": "18", "end_hour": "9"} and print form.errors.
Open the Python workspaceCheck your understanding
A form defines clean_username() that lowercases self.cleaned_data["username"] but the method body ends without a return statement. The submitted username is otherwise valid. What happens?
- is_valid() returns False and username gets a "This field is required." error
- cleaned_data["username"] keeps the original mixed-case string, since nothing replaced it
- is_valid() returns True and cleaned_data["username"] is None
- Django raises ValidationError because clean_username returned no value
Show answer
Django assigns whatever clean_<field>() returns into cleaned_data, and a method without a return returns None, so the key exists with value None and no error is recorded. Keeping the original string is tempting but wrong: the hook's return value overwrites the entry unconditionally rather than being treated as an optional override.