Skip to main content

🛡️ 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 override clean() 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.

flowchart TD A[form.is_valid called] --> B[Field: to_python + validate + run validators] B --> C[clean_<field> methods] C --> D[Form-wide clean method] D --> E{Any errors?} E -->|No| F[cleaned_data complete] E -->|Yes| G[form.errors populated] G --> H[Re-render with messages]

Two consequences worth memorizing:

  • If a field fails its own validation, it never reaches cleaned_data — so in clean() you must use cleaned_data.get("x") (which may return None), not cleaned_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>
Field errors versus non-field errors Non-field errors appear in a banner at the top of the form; field errors appear next to the input they belong to. ⚠ Non-field error: passwords don't match (form.non_field_errors) Email ✗ Field error: enter a valid email address (field.errors)
Figure 1 — Non-field errors sit in a banner up top; each field error sits beside the input that produced it.

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

  1. Start from a SignupForm with username, email, password1, and password2 (both PasswordInput).
  2. Add a clean_username() that rejects a username shorter than 4 characters or already present in User.
  3. Override clean() to require that password1 and password2 match, attaching the error to password2.
  4. Also in clean(), raise a non-field ValidationError if 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 in clean().
  • Push data-integrity rules down to the model so every save path is protected.
  • Always return the value from a clean_<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"] inside clean(); 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 and raise ValidationError in clean() 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.