📝 Forms and Form Validation
Every place a user types something into your site — a login box, a checkout, a comment — a form stands between them and your database. Django's form layer handles the rendering, the validation, and the cleaning so you don't have to hand-parse raw POST data (and quietly ship security holes doing it).
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why Django forms beat parsing
request.POSTby hand - Distinguish fields (validation) from widgets (rendering) and configure both
- Trace the validation pipeline and add custom rules with
clean_<field>()andclean() - Use ModelForm to build create/edit forms straight from a model
- Render forms in templates cleanly and write tests that prove your validation works
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Build and validate a signup form with a matching-password rule, then test it.
In This Lesson
Why Django Forms?
You could read request.POST["email"] directly and validate it yourself. People do — and they forget an edge case, skip CSRF protection, or trust input they shouldn't. Django's form system gives you a single place to declare what valid input looks like, and then does the tedious, security-critical work for you.
💡 The receptionist analogy: A Django form is your application's front-desk receptionist. It greets incoming data, checks it's complete and correctly formatted, politely turns away anything invalid with a clear note, and only forwards clean, trustworthy information to the rest of the office (your views and models).
✅ What the form layer handles for you
- Validation — type checks, required fields, length, email/URL format, and your own rules.
- Rendering — HTML inputs with labels, IDs, help text, and inline error messages.
- Cleaning — converting raw strings into proper Python types (dates, ints, booleans).
- Security — CSRF protection via
{% csrf_token %}, and server-side validation you can't bypass from the client. - DRY — declare requirements once, reuse the form across views and tests.
is_valid() sorts it into cleaned_data (process it) or errors (re-render the form with messages).Your First Form
A form is a Python class whose attributes are fields. Here's a contact form:
# forms.py
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
The view drives the two-phase lifecycle: an unbound form on GET, a bound form on POST:
# views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import ContactForm
def contact_view(request):
if request.method == "POST":
form = ContactForm(request.POST) # bound form
if form.is_valid():
data = form.cleaned_data # now safe, typed values
# ... send an email, save a record, etc.
messages.success(request, "Thanks — we'll be in touch!")
return redirect("contact")
else:
form = ContactForm() # unbound (empty) form
return render(request, "contact.html", {"form": form})
<!-- contact.html -->
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Send</button>
</form>
⚠️ Never skip {% csrf_token %}
Without it, Django rejects the POST with a 403. That token is your defence against cross-site request forgery — an attacker tricking a logged-in user's browser into submitting a form they never meant to. It is not optional.
{{ form.as_p }} renders every field wrapped in a <p>. Django also offers as_div (the modern default helper), as_ul, and as_table — but for real projects you'll usually render fields by hand (see below).
Fields vs. Widgets
Django deliberately splits two concerns. A field handles validation and converts input to a Python value. A widget handles how the field is drawn in HTML. One field can use different widgets, and one widget can serve different fields.
Common fields
| Field | Validates / returns | Typical use |
|---|---|---|
CharField | String, optional length limits | Names, titles |
EmailField | Valid email string | Contact email |
IntegerField | Python int, optional min/max | Age, quantity |
DateField | Python date | Birth date, event date |
ChoiceField | One value from a fixed set | Dropdowns |
BooleanField | Python bool | Opt-in checkboxes |
FileField | Uploaded file | Attachments |
Customizing with widgets
Widgets take an attrs dict that becomes HTML attributes — the hook for CSS classes, placeholders, and HTML5 input types:
class EventForm(forms.Form):
# HTML5 date picker
event_date = forms.DateField(
widget=forms.DateInput(attrs={"type": "date", "class": "input"})
)
# Placeholder text
username = forms.CharField(
widget=forms.TextInput(attrs={"placeholder": "Choose a username"})
)
# Dropdown
category = forms.ChoiceField(choices=[
("tech", "Technology"),
("health", "Health & Wellness"),
("edu", "Education"),
])
# Checkbox group
interests = forms.MultipleChoiceField(
choices=[("code", "Coding"), ("design", "Design"), ("write", "Writing")],
widget=forms.CheckboxSelectMultiple,
)
📖 Definition — attrs
The attrs dictionary on a widget maps directly to HTML attributes on the rendered tag. {"class": "input", "placeholder": "…"} becomes class="input" placeholder="…". It's how you connect Django forms to your CSS framework.
The Validation Pipeline
Calling form.is_valid() runs a well-defined sequence. Understanding the order tells you exactly where to put a given rule.
Field-level: clean_<field>()
For one field's rules, use built-in validators or a clean_<fieldname> method. It must return the cleaned value:
from django import forms
from django.core.validators import MinLengthValidator, RegexValidator
class SignupForm(forms.Form):
username = forms.CharField(validators=[
MinLengthValidator(4, "Username must be at least 4 characters."),
RegexValidator(r"^[a-zA-Z0-9_]+$", "Letters, numbers, and underscores only."),
])
def clean_username(self):
username = self.cleaned_data["username"]
if username.lower() in {"admin", "root"}:
raise forms.ValidationError("That username is reserved.")
return username # ← always return the value
Form-level: clean()
When a rule spans multiple fields — passwords matching, end date after start date — override clean():
class SignupForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput)
confirm_password = forms.CharField(widget=forms.PasswordInput)
def clean(self):
cleaned = super().clean()
pw = cleaned.get("password")
confirm = cleaned.get("confirm_password")
if pw and confirm and pw != confirm:
# Attach the error to a specific field...
self.add_error("confirm_password", "Passwords don't match.")
return cleaned
💡 add_error() vs. raising ValidationError
Raising ValidationError inside clean() attaches the message to the whole form (shown by form.non_field_errors). Calling self.add_error("field", ...) attaches it next to a specific field — usually friendlier for users. Use .get() (not []) inside clean(), because a field that failed earlier won't be in cleaned_data.
ModelForms
When a form maps directly onto a model, don't retype the fields — let ModelForm generate them from the model definition, including validation and a working save().
# models.py
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
pub_date = models.DateField(auto_now_add=True)
is_published = models.BooleanField(default=False)
# forms.py
from django import forms
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "content", "is_published"] # explicit is best
widgets = {
"content": forms.Textarea(attrs={"rows": 12}),
}
⚠️ Prefer fields over fields = "__all__"
Listing fields explicitly is a security habit: "__all__" auto-includes any field you later add to the model — including ones users should never set. Name the fields you actually want editable.
The view is almost identical to a plain form, but save() now writes to the database:
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from .forms import ArticleForm
from .models import Article
def create_article(request):
form = ArticleForm(request.POST or None)
if request.method == "POST" and form.is_valid():
article = form.save() # INSERT
return redirect("article_detail", pk=article.pk)
return render(request, "article_form.html", {"form": form})
def edit_article(request, pk):
article = get_object_or_404(Article, pk=pk)
form = ArticleForm(request.POST or None, instance=article)
if request.method == "POST" and form.is_valid():
form.save() # UPDATE the same row
return redirect("article_detail", pk=article.pk)
return render(request, "article_form.html", {"form": form})
Pass instance=article and the same form both pre-populates for editing and updates that row on save — one form class powers both create and edit.
Rendering in Templates
{{ form.as_p }} is fine for a prototype. For production you'll want control over markup and CSS. Loop over the fields:
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert">{{ form.non_field_errors }}</div>
{% endif %}
{% for field in form %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<small class="help">{{ field.help_text }}</small>
{% endif %}
{{ field.errors }}
</div>
{% endfor %}
<button type="submit">Submit</button>
</form>
💡 Why novalidate?
Adding novalidate to the <form> turns off the browser's own validation so you can see and test Django's server-side messages during development. In production you may keep client-side hints — but remember the server is the only validation you can trust.
Worked Example: Registration Form
Let's combine everything into a conference-registration ModelForm with extra non-model fields (email confirmation, terms), cross-field validation, and conditional validation.
# models.py
from django.db import models
class Participant(models.Model):
SHIRT_SIZES = [("S", "Small"), ("M", "Medium"), ("L", "Large"), ("XL", "X-Large")]
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
company = models.CharField(max_length=200, blank=True)
shirt_size = models.CharField(max_length=2, choices=SHIRT_SIZES)
has_dietary_needs = models.BooleanField(default=False)
dietary_notes = models.TextField(blank=True)
registered_at = models.DateTimeField(auto_now_add=True)
# forms.py
from django import forms
from .models import Participant
class ParticipantForm(forms.ModelForm):
# Fields that are NOT on the model:
confirm_email = forms.EmailField(label="Confirm email")
agree_to_terms = forms.BooleanField(
required=True, label="I agree to the conference terms."
)
class Meta:
model = Participant
fields = [
"first_name", "last_name", "email", "company",
"shirt_size", "has_dietary_needs", "dietary_notes",
]
widgets = {"dietary_notes": forms.Textarea(attrs={"rows": 3})}
help_texts = {"email": "We'll send your confirmation here."}
def clean(self):
cleaned = super().clean()
# Cross-field: emails must match.
if cleaned.get("email") and cleaned.get("confirm_email"):
if cleaned["email"] != cleaned["confirm_email"]:
self.add_error("confirm_email", "Email addresses must match.")
# Conditional: notes required only if the box is checked.
if cleaned.get("has_dietary_needs") and not cleaned.get("dietary_notes"):
self.add_error("dietary_notes", "Please describe your dietary needs.")
return cleaned
# views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import ParticipantForm
def register(request):
form = ParticipantForm(request.POST or None)
if request.method == "POST" and form.is_valid():
participant = form.save()
messages.success(request, f"Thanks, {participant.first_name}! You're registered.")
return redirect("register_done")
return render(request, "register.html", {"form": form})
✅ What this example demonstrates
- A
ModelFormextended with extra fields not stored on the model. - Cross-field validation (email vs. confirm_email) via
clean(). - Conditional validation (notes required only when a box is ticked).
- Per-field help text and widget customization through
Meta.
Testing Forms
Forms are pure logic — no browser needed — which makes them fast and satisfying to test. Instantiate with a data dict and assert on validity and error messages:
# tests.py
from django.test import TestCase
from .forms import ParticipantForm
class ParticipantFormTests(TestCase):
def base_data(self, **overrides):
data = {
"first_name": "Ada", "last_name": "Lovelace",
"email": "ada@example.com", "confirm_email": "ada@example.com",
"shirt_size": "M", "has_dietary_needs": False,
"dietary_notes": "", "agree_to_terms": True,
}
data.update(overrides)
return data
def test_valid_registration(self):
form = ParticipantForm(self.base_data())
self.assertTrue(form.is_valid())
def test_mismatched_emails_rejected(self):
form = ParticipantForm(self.base_data(confirm_email="typo@example.com"))
self.assertFalse(form.is_valid())
self.assertIn("confirm_email", form.errors)
def test_dietary_notes_required_when_checked(self):
form = ParticipantForm(self.base_data(has_dietary_needs=True, dietary_notes=""))
self.assertFalse(form.is_valid())
self.assertIn("dietary_notes", form.errors)
💡 Assert on the field, not the exact wording
Checking form.errors["confirm_email"] is more robust than matching the message string — you can reword the error later without breaking the test.
Hands-on Exercise
🏋️ Build a newsletter signup form
Objective: Write a plain forms.Form called NewsletterForm with an email, a confirm_email, and a required consent checkbox. Reject the form unless the two emails match and consent is given.
Requirements
- Three fields: two
EmailFields and a requiredBooleanField. - A
clean()method that adds an error toconfirm_emailwhen the emails differ. - One test proving a mismatched pair is invalid.
💡 Hint
Use .get() inside clean() so a missing value doesn't raise a KeyError. A BooleanField with required=True already forces the box to be ticked — no extra rule needed for consent.
✅ Sample solution
# forms.py
from django import forms
class NewsletterForm(forms.Form):
email = forms.EmailField(label="Email")
confirm_email = forms.EmailField(label="Confirm email")
consent = forms.BooleanField(
required=True, label="I agree to receive emails."
)
def clean(self):
cleaned = super().clean()
email = cleaned.get("email")
confirm = cleaned.get("confirm_email")
if email and confirm and email != confirm:
self.add_error("confirm_email", "Email addresses must match.")
return cleaned
# tests.py
from django.test import TestCase
from .forms import NewsletterForm
class NewsletterFormTests(TestCase):
def test_mismatch_is_invalid(self):
form = NewsletterForm({
"email": "a@example.com",
"confirm_email": "b@example.com",
"consent": True,
})
self.assertFalse(form.is_valid())
self.assertIn("confirm_email", form.errors)
Summary & Quiz
🎉 Key Takeaways
- Django forms centralize validation, rendering, cleaning, and CSRF protection.
- Fields validate and convert; widgets render — customize widgets via
attrs. - Put single-field rules in
clean_<field>(); put cross-field rules inclean(). - ModelForm generates fields and a
save()from a model; listfieldsexplicitly. - Forms are plain Python — test them fast by asserting on
is_valid()andform.errors.
🎯 Quick Quiz
Question 1: Where should a rule that compares two fields (e.g. password and confirm-password) live?
Question 2: In Django's form design, what is a widget responsible for?
Question 3: Why prefer an explicit fields list over fields = "__all__" in a ModelForm's Meta?
📚 Further Reading
- Django docs — Working with forms
- Django docs — Form and field validation
- Django docs — Creating forms from models (ModelForm)
🚀 What's Next?
Forms handle the "who typed what" — next we handle "who are they" with Django's built-in authentication system: users, login, permissions, and sessions.
🎉 Great work!
You can now accept, validate, and trust user input the Django way. On to authentication.