ScreenshotNeo

BlogGuides

Django Form Validation: How to Validate Forms with Django

Learn Django form validation from field checks to ModelForm constraints, custom errors, testing, troubleshooting, and production patterns.

By the ScreenshotNeo team1 October 20268 min read

Django validates submitted data through a predictable pipeline. Bind request data to a form, call is_valid(), read cleaned_data only after it succeeds, and place each rule at the narrowest level that can express it. Use field validators or clean_<field>() for one field, clean() for relationships between fields, and Model.full_clean() when validating model instances outside a ModelForm.

This guide follows Django’s documented validation behavior in the form validation reference and model instance reference.

1. The basic validation workflow

  1. Create a form class.
  2. Bind submitted values with request.POST (and request.FILES for uploads).
  3. Call form.is_valid(). This runs field cleaning and form-wide cleaning.
  4. Use form.cleaned_data only when the form is valid.
  5. Render form.errors when validation fails.
from django.shortcuts import render, redirect
from .forms import SignupForm

def signup(request):
    if request.method == "POST":
        form = SignupForm(request.POST)
        if form.is_valid():
            # Values are normalized Python objects here.
            email = form.cleaned_data["email"]
            display_name = form.cleaned_data["display_name"]
            # Create the account or call a service here.
            return redirect("signup-success")
    else:
        form = SignupForm()

    return render(request, "accounts/signup.html", {"form": form})

2. What Django runs when you call is_valid()

A bound form is one initialized with submitted data. Calling is_valid(), accessing errors, or calling full_clean() starts the cleaning process. Django performs these steps for each field:

  1. Convert the raw string or uploaded value to a Python value.
  2. Check required fields and empty values.
  3. Run the field’s validators.
  4. Run a clean_<fieldname>() method, if defined.
  5. Run the form’s clean() method for cross-field rules.

A valid DateField, for example, produces a datetime.date, not the original text. Invalid fields are omitted from cleaned_data. Errors raised by a field are attached to that field; errors raised by clean() are normally non-field errors unless you assign them explicitly.

3. A complete custom form example

The following form demonstrates required fields, reusable validators, field-specific cleaning, and a cross-field rule.

# forms.py
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator


def validate_company_email(value):
    if not value.lower().endswith("@example.com"):
        raise ValidationError("Use your example.com work email.")


class RegistrationForm(forms.Form):
    display_name = forms.CharField(
        max_length=80,
        validators=[MinLengthValidator(2, "Enter at least two characters.")],
    )
    email = forms.EmailField(validators=[validate_company_email])
    password = forms.CharField(min_length=12, widget=forms.PasswordInput)
    password_confirm = forms.CharField(widget=forms.PasswordInput)
    start_date = forms.DateField(input_formats=["%Y-%m-%d"])
    marketing_opt_in = forms.BooleanField(required=False)

    def clean_display_name(self):
        value = self.cleaned_data["display_name"].strip()
        if "<" in value or ">" in value:
            raise ValidationError("HTML is not allowed in the display name.")
        return value

    def clean(self):
        cleaned = super().clean()
        password = cleaned.get("password")
        confirmation = cleaned.get("password_confirm")
        if password and confirmation and password != confirmation:
            self.add_error("password_confirm", "Passwords do not match.")

        start_date = cleaned.get("start_date")
        if start_date and start_date.weekday() >= 5:
            self.add_error("start_date", "Choose a weekday.")
        return cleaned

Use super().clean() first. It preserves the cleaned values produced by Django and, for ModelForm, preserves built-in uniqueness checks.

4. Field-level validation choices

Technique Use it when Example
Built-in field The rule is standard EmailField, URLField, IntegerField
validators=[...] The rule is reusable across forms or models MinValueValidator(1)
clean_<field>() The rule needs form state but belongs to one field Normalize or reject a username
clean() The rule compares two or more fields Matching passwords or date ranges
from django import forms
from django.core.validators import RegexValidator

class CouponForm(forms.Form):
    code = forms.CharField(
        max_length=20,
        validators=[RegexValidator(r"^[A-Z0-9-]+$", "Use uppercase letters, numbers, and hyphens.")],
    )
    quantity = forms.IntegerField(min_value=1, max_value=100)
    notes = forms.CharField(required=False)

Set required=False when an empty value is valid. Do not treat an empty string as equivalent to a missing value unless your business rule explicitly does so.

5. Cross-field validation and error placement

Use clean() for relationships that cannot be checked by one field alone. Call add_error(field, message) when the message should appear beside a specific input. Raising ValidationError from clean() creates a non-field error displayed through form.non_field_errors.

class BookingForm(forms.Form):
    check_in = forms.DateField()
    check_out = forms.DateField()
    guests = forms.IntegerField(min_value=1)

    def clean(self):
        cleaned = super().clean()
        check_in = cleaned.get("check_in")
        check_out = cleaned.get("check_out")
        if check_in and check_out and check_out <= check_in:
            self.add_error("check_out", "Check-out must be after check-in.")
        return cleaned

6. Displaying errors in templates

<form method="post">
  {% csrf_token %}
  {{ form.non_field_errors }}
  {{ form.display_name.errors }}
  {{ form.display_name.label_tag }}
  {{ form.display_name }}
  {{ form.email.errors }}
  {{ form.email.label_tag }}
  {{ form.email }}
  {{ form.password.errors }}
  {{ form.password }}
  {{ form.password_confirm.errors }}
  {{ form.password_confirm }}
  <button type="submit">Create account</button>
</form>

For a generic renderer, iterate over form.visible_fields and render each field’s errors. Keep non-field errors near the top of the form so users can see cross-field failures.

7. Validating a ModelForm

ModelForm.is_valid() validates the form fields and then validates the model instance. Django runs your form’s clean() before model validation. Fields omitted from the form are excluded from the corresponding model field checks so users are not shown errors for values they cannot edit.

# models.py
from django.db import models

class Invitation(models.Model):
    email = models.EmailField(unique=True)
    role = models.CharField(max_length=30)
    expires_at = models.DateTimeField()

# forms.py
from django import forms
from .models import Invitation

class InvitationForm(forms.ModelForm):
    class Meta:
        model = Invitation
        fields = ["email", "role", "expires_at"]

    def clean(self):
        cleaned = super().clean()
        role = cleaned.get("role")
        if role == "owner" and self.initial.get("role") != "owner":
            raise forms.ValidationError("Only an existing owner can grant the owner role.")
        return cleaned

Calling super().clean() matters when you want Django’s uniqueness checks for unique, unique_together, and unique_for_date, unique_for_month, or unique_for_year to remain active.

8. Model validation and save()

Model.full_clean() runs four stages in order: clean_fields(), clean(), validate_unique(), and validate_constraints(). Calling save() does not call full_clean() automatically.

from django.core.exceptions import ValidationError
from .models import Invitation

invitation = Invitation(
    email="person@example.com",
    role="member",
    expires_at=some_datetime,
)
try:
    invitation.full_clean()
except ValidationError as exc:
    # exc.message_dict contains field and non-field errors.
    handle_validation_errors(exc.message_dict)
else:
    invitation.save()

Call full_clean() when application code creates model instances directly and must handle validation errors before saving. Database constraints still protect against races; form validation is not a replacement for transactional database enforcement.

9. Files and multiple forms

Bind file uploads with both dictionaries:

form = ProfileForm(request.POST, request.FILES)

For multiple forms on one page, give each a prefix so fields and errors do not collide:

profile = ProfileForm(request.POST, prefix="profile")
preferences = PreferencesForm(request.POST, prefix="preferences")
if profile.is_valid() and preferences.is_valid():
    ...

10. Testing validation

from django.test import TestCase
from .forms import BookingForm

class BookingFormTests(TestCase):
    def test_checkout_must_follow_checkin(self):
        form = BookingForm(data={
            "check_in": "2026-05-10",
            "check_out": "2026-05-09",
            "guests": "2",
        })
        self.assertFalse(form.is_valid())
        self.assertIn("check_out", form.errors)

    def test_cleaned_values_are_normalized(self):
        form = BookingForm(data={
            "check_in": "2026-05-10",
            "check_out": "2026-05-12",
            "guests": "2",
        })
        self.assertTrue(form.is_valid())
        self.assertEqual(form.cleaned_data["guests"], 2)

11. Common errors and fixes

Symptom Cause Fix
cleaned_data is missing a key The field failed validation or was omitted Check is_valid() and use cleaned_data.get() inside cross-field cleaning.
Custom error never appears The method is misspelled or raises the wrong exception Name it exactly clean_<fieldname> and raise ValidationError.
Cross-field code crashes One field failed before clean() Read values with cleaned_data.get() and guard for None.
Uniqueness errors disappeared ModelForm.clean() did not call its parent Start with cleaned = super().clean().
Model saves invalid data save() does not run full_clean() Call full_clean() explicitly or enforce the rule with database constraints.
File field is always empty request.FILES was not bound or the form lacks multipart encoding Pass both dictionaries and use enctype="multipart/form-data".
Errors are not shown The template renders widgets but not error lists Render each field’s errors and form.non_field_errors.

12. Performance, reliability, and security notes

  • Keep inexpensive, deterministic checks in field validators. Avoid database queries in a validator that runs for every keystroke in an API or live form.
  • Use database unique constraints for race-safe uniqueness. A form check improves feedback but two requests can still pass it concurrently.
  • Do not trust hidden or disabled fields; validate permissions and ownership in the view or service layer.
  • Normalize values before comparisons, such as trimming names and comparing case-insensitive email addresses according to your account policy.
  • Use CSRF protection for browser POST forms and return a fresh form after a successful POST/redirect.
  • For expensive external checks, validate syntax synchronously and perform slow verification asynchronously after the form is accepted.

13. Or skip the browser setup

If your Django workflow also needs screenshots of submitted pages, dashboards, or validation states, ScreenshotNeo provides a single HTTP request instead of maintaining browser automation. It removes cookie banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; and its MCP server lets AI agents take screenshots.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());

You get 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

14. FAQ

Should I use clean() or clean_<field>()?

Use the field hook when one field owns the rule. Use clean() when the result depends on multiple fields.

Does reading form.errors validate the form?

Yes. Accessing errors starts the same cleaning process as is_valid().

Can I rely on ModelForm to validate excluded model fields?

No. Fields excluded from the form are excluded from that form’s model validation and may need explicit validation elsewhere.

When should an API call full_clean()?

Call it when the API constructs model instances directly and must return validation errors before saving. Keep database constraints for race-safe enforcement.