Form Validation & Validators

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example on the page was run in a scratch project (an app called catalog, SQLite, Django’s test runner and the runserver development server with curl); none of them needs PostgreSQL. Validation is the step between "the browser sent something" and "the application trusts it". Django splits it into small, ordered stages so that each rule lives in exactly one place: type coercion in the field, reusable rules in validators, per-field business rules in clean_<field>(), cross-field rules in clean(), and the rules that belong to the data itself on the model and in its constraints. This page walks through those stages for the Bookshelf catalogue, shows how to report errors with add_error() and well-formed ValidationError objects, how to write reusable validators, how model validation and constraint validation work and when they run, and how ModelForm stitches the two worlds together. The form basics are on Forms; saving through ModelForm is on Model Forms & Formsets.

In a hurry: where does each rule go

Rule Put it in Why

"Must be an integer", "must be a date"

The field class (to_python())

Type coercion is the field’s job and runs first.

Same check on many fields, forms and models (ISBN, slug, no angle brackets)

A validator in validators=[…​]

One callable works on form fields and model fields alike.

Rule about one field, specific to this form

clean_<fieldname>()

Runs after the field’s own checks; returns the cleaned value.

Rule about several fields of a form

Form.clean() plus add_error()

Runs once every field has been cleaned, even when some failed.

Rule about the data, whatever the entry point (form, admin, API, shell)

Model.clean() and field validators on the model

Runs from full_clean() and therefore from every ModelForm.

Rule the database must guarantee (unique, check)

Meta.constraints

Enforced by the database, and also checked during model validation for friendly errors.

The validation cycle

Cleaning a bound form is triggered by is_valid(), by reading form.errors or by calling full_clean() directly. For every field, in the order the fields are declared, Django runs Field.clean(), which calls three methods in a fixed order, and then the form’s clean_<fieldname>() method if there is one. When all fields are done, Form.clean() runs.

flowchart TD A[Bound form: is_valid or errors] --> B[For each field, in declaration order] B --> C[Field.to_python: coerce raw value] C --> D[Field.validate: field-specific checks, required] D --> E[Field.run_validators: validators list] E -->|ValidationError| F[Error stored for this field, clean_fieldname skipped] E -->|ok| G[clean_fieldname: form-specific rule] G -->|ValidationError| H[Error stored, value dropped from cleaned_data] G -->|ok| I[Value stored in cleaned_data] F --> J[Next field] H --> J I --> J J --> K[Form.clean: cross-field rules, always runs] K --> L{ModelForm?} L -->|yes| M[Model full_clean without unique/constraints, then validate_unique, then validate_constraints] L -->|no| N[is_valid is True when no errors] M --> N

The following points follow from the documented behaviour:

  • If to_python(), validate() or run_validators() raises, the field’s error is recorded and its clean_<fieldname>() is not called, but the remaining fields are still cleaned.

  • Form.clean() runs whether or not earlier steps raised, so cleaned_data may lack fields that failed; always use .get().

  • The return value of clean_<fieldname>() replaces the value in cleaned_data, so it must return the value (changed or not). A clean_<fieldname>() that forgets to return puts None in cleaned_data.

  • Field.clean() stops at the first stage that raises, but run_validators() itself collects the errors of all the field’s validators into one error.

  • Cleaning is synchronous and may query the database (for unique checks in a ModelForm); see Sync and async.

The best way to see the order is to log it. This custom field accepts a comma-separated list of email addresses, a shape the official documentation also uses to explain the cycle. It overrides all three field stages, and the form adds a clean_recipients() and a clean():

catalog/forms.py: a field and a form that expose the cycle
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import validate_email

trace = []  # for the demonstration only


class MultiEmailField(forms.Field):
    def to_python(self, value):
        trace.append("MultiEmailField.to_python")
        if not value:
            return []
        return [v.strip() for v in value.split(",")]

    def validate(self, value):
        trace.append("MultiEmailField.validate")
        super().validate(value)          # handles required=True
        for email in value:
            validate_email(email)

    def run_validators(self, value):
        trace.append("MultiEmailField.run_validators")
        super().run_validators(value)    # normally you never override this


class ShareBookForm(forms.Form):
    subject = forms.CharField(max_length=100)
    message = forms.CharField(widget=forms.Textarea)
    recipients = MultiEmailField()
    urgent = forms.BooleanField(required=False)

    def clean_recipients(self):
        trace.append("clean_recipients")
        data = self.cleaned_data["recipients"]
        if not all(e.endswith("@example.com") for e in data):
            raise ValidationError(
                "All recipients must be @example.com.", code="wrong_domain"
            )
        return data

    def clean(self):
        trace.append("clean")
        return super().clean()

A test pins the order, and also shows that a field error skips clean_recipients() but not clean():

catalog/tests.py: the cycle in a test
class FormCycleTests(TestCase):
    def test_order(self):
        forms_module.trace.clear()
        f = ShareBookForm(
            {"subject": "hi", "message": "m", "recipients": "a@example.com", "urgent": ""}
        )
        self.assertTrue(f.is_valid())
        self.assertEqual(forms_module.trace, [
            "MultiEmailField.to_python", "MultiEmailField.validate",
            "MultiEmailField.run_validators", "clean_recipients", "clean"])

    def test_field_error_skips_clean_field_but_not_clean(self):
        forms_module.trace.clear()
        f = ShareBookForm({"subject": "hi", "message": "m", "recipients": "nope"})
        self.assertFalse(f.is_valid())
        self.assertNotIn("clean_recipients", forms_module.trace)
        self.assertIn("clean", forms_module.trace)
Running the checks and the tests
$ python manage.py check
System check identified no issues (0 silenced).
$ python manage.py test catalog
Found 25 test(s).
...
Ran 25 tests in 0.034s

OK

Raising ValidationError well

Every stage reports a problem by raising django.core.exceptions.ValidationError. A bare string works, but the form of the exception determines how reusable the error is. Give it a code (stable, not translated, usable in tests and APIs), put variables in params with named placeholders (so translators can reorder them), and mark the message for translation with gettext/gettext_lazy (see Internationalization & Translation). To report several problems at once, pass a list of ValidationError instances.

A well-formed error and a list of errors
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

raise ValidationError(
    _("Invalid value: %(value)s"),
    code="invalid",
    params={"value": "42"},
)

raise ValidationError(
    [
        ValidationError(_("Error 1"), code="error1"),
        ValidationError(_("Error 2"), code="error2"),
    ]
)

Codes and params pay off when you read the errors back: form.errors.as_data() returns the original ValidationError objects, form.errors.get_json_data() (or as_json()) returns messages with codes, and form.has_error(field, code) answers "did this field fail for this reason" without comparing translated text. A ValidationError raised from Form.clean() (or a ModelForm’s model-level `clean()) belongs to no field and lands in the special __all__ key, available through form.non_field_errors() and rendered by {{ form.non_field_errors }} in a template.

add_error(): attaching errors to fields

Form.add_error(field, error) is how clean() reports a cross-field problem on a specific field, or on several fields, instead of the form as a whole. The first argument is a field name or None for a non-field error; the second is a string, a ValidationError, a list of them, or a dict. add_error() removes the field from cleaned_data (the data is no longer trusted), raises ValueError for a field that is not in the form, and can be called more than once per field.

catalog/forms.py: cross-field rules with add_error()
class ShareBookForm(forms.Form):
    ...  # fields, clean_recipients as before

    def clean(self):
        cleaned_data = super().clean()
        urgent = cleaned_data.get("urgent")
        subject = cleaned_data.get("subject")
        if urgent and subject and "help" not in subject:
            self.add_error(
                "subject",
                ValidationError(
                    "Urgent requests need %(word)r in the subject.",
                    code="needs_help_word",
                    params={"word": "help"},
                ),
            )
            self.add_error("urgent", "Do not mark it urgent without that word.")
        if len(cleaned_data.get("recipients", [])) > 5:
            self.add_error(None, "At most five recipients are allowed.")
        return cleaned_data

Reading the result in a shell shows the three destinations (two fields and the non-field key) and the codes:

Inspecting errors from the shell
>>> f = ShareBookForm({"subject": "hi", "message": "m", "urgent": "on",
...     "recipients": "a@example.com,b@example.com,c@example.com,d@example.com,e@example.com,f@example.com"})
>>> f.is_valid()
False
>>> f.errors.get_json_data()
{'subject': [{'message': "Urgent requests need 'help' in the subject.", 'code': 'needs_help_word'}],
 'urgent': [{'message': 'Do not mark it urgent without that word.', 'code': ''}],
 '__all__': [{'message': 'At most five recipients are allowed.', 'code': ''}]}
>>> f.has_error("subject", code="needs_help_word")
True
>>> sorted(f.cleaned_data)       # subject and urgent were removed by add_error()
['message', 'recipients']
>>> f.non_field_errors()
<ul class="errorlist nonfield"><li>At most five recipients are allowed.</li></ul>

Source: Form.errors.

Raising from clean() and calling add_error(None, …​) are equivalent for a single non-field message; prefer add_error() when you want to report several things in one pass, because a raised exception ends the method. Error rendering (the errorlist markup, aria-describedby and friends) is the subject of Form Rendering & Widgets.

Validators: reusable rules

A validator is any callable that takes a value, returns nothing when the value is acceptable and raises ValidationError otherwise. Validators run after to_python() and validate(), so they receive Python objects (an int, a date, a str), which is why one validator can be shared by a form field and a model field. They are attached with the validators argument or, for a field class, the default_validators attribute; Django’s own SlugField is a CharField whose default_validators is [validate_slug].

Function validators

The ISBN-13 check digit is a natural candidate: the same rule applies when a book is entered in a form, in the admin, in an API serializer or from a script.

catalog/validators.py: a function validator
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _


def validate_isbn13(value):
    """Reject anything that is not a well-formed ISBN-13 (hyphens allowed)."""
    digits = value.replace("-", "")
    if len(digits) != 13 or not digits.isdigit():
        raise ValidationError(
            _("%(value)s is not a 13-digit ISBN."),
            code="isbn_format",
            params={"value": value},
        )
    total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits))
    if total % 10 != 0:
        raise ValidationError(
            _("%(value)s has a wrong ISBN-13 check digit."),
            code="isbn_checksum",
            params={"value": value},
        )

The same function is used on a plain form field and, later on this page, on the Book model field:

One validator, a form field
class IsbnLookupForm(forms.Form):
    isbn = forms.CharField(validators=[validate_isbn13])
catalog/tests.py: testing a validator on its own and through a form
def test_isbn(self):
    validate_isbn13("978-0-306-40615-7")
    with self.assertRaises(ValidationError) as cm:
        validate_isbn13("978-0-306-40615-8")
    self.assertEqual(cm.exception.code, "isbn_checksum")

def test_same_validator_on_form(self):
    f = IsbnLookupForm({"isbn": "978-0-306-40615-8"})
    self.assertFalse(f.is_valid())
    self.assertEqual(f.errors["isbn"], ["978-0-306-40615-8 has a wrong ISBN-13 check digit."])

Class-based, configurable validators

When a validator needs configuration, write a class with __call__. If it will be used in a model field, migrations serialise the validator instance into the migration file, so decorate it with @deconstructible and implement __eq__ so the autodetector does not generate a new migration on every run. Functions are serialised by import path and need none of this.

catalog/validators.py: a deconstructible class validator
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _


@deconstructible
class MaxWordsValidator:
    message = _("Use at most %(limit)d words (you used %(count)d).")
    code = "max_words"

    def __init__(self, limit, message=None, code=None):
        self.limit = limit
        if message is not None:
            self.message = message
        if code is not None:
            self.code = code

    def __call__(self, value):
        count = len(value.split())
        if count > self.limit:
            raise ValidationError(
                self.message,
                code=self.code,
                params={"limit": self.limit, "count": count, "value": value},
            )

    def __eq__(self, other):
        return (
            isinstance(other, MaxWordsValidator)
            and self.limit == other.limit
            and self.message == other.message
            and self.code == other.code
        )

makemigrations writes catalog.validators.MaxWordsValidator(50) into the migration, and the test MaxWordsValidator(3).deconstruct()[0] == "catalog.validators.MaxWordsValidator" confirms the import path. See Migrations for what happens to the migration file when a validator is later renamed or moved.

Built-in validators

django.core.validators ships a set that you can combine with your own and that the built-in fields already use. The table lists the ones you will meet most often; every one raises a ValidationError with the documented code.

Validator Checks Code

RegexValidator(regex, message, code, inverse_match, flags)

re.search() of the pattern in the value; inverse_match=True raises on a match instead. Base of several others.

invalid

EmailValidator, validate_email

Looks like an email address; values over 320 characters are invalid; domains without a dot need the allowlist.

invalid

URLValidator(schemes=…​)

A URL with a host; default schemes http, https, ftp, ftps; max length 2048.

invalid

DomainNameValidator, validate_domain_name

A domain name (IDNA accepted by default); IP addresses are rejected.

invalid

validate_slug, validate_unicode_slug

Letters, digits, underscores and hyphens (the Unicode variant allows Unicode letters).

invalid

validate_ipv4_address, validate_ipv6_address, validate_ipv46_address

IP address syntax.

invalid

MinValueValidator, MaxValueValidator

Value bounds; the limit may be a callable.

min_value, max_value

MinLengthValidator, MaxLengthValidator

Length bounds; the limit may be a callable.

min_length, max_length

StepValueValidator(limit, offset=…​)

Value is an integral multiple of the step (plus optional offset).

step_size

DecimalValidator(max_digits, decimal_places)

Digit counts of a Decimal.

max_digits, max_decimal_places, max_whole_digits

FileExtensionValidator(allowed_extensions)

Case-insensitive extension of value.name. It is not a content check; see File Uploads & Storage.

invalid_extension

ProhibitNullCharactersValidator

No NUL characters; CharField adds it by default.

null_characters_not_allowed

RegexValidator is the quickest way to build a one-off rule and, being a validator instance, works in both worlds:

catalog/validators.py: a RegexValidator instance
from django.core.validators import RegexValidator

validate_slugish_title = RegexValidator(
    regex=r"^[^<>]*$",
    message=_("Titles may not contain angle brackets."),
    code="no_angle_brackets",
)

Source: RegexValidator.

Model validation

Validators on a model field, Model.clean(), uniqueness checks and constraints form a second validation layer that is independent of forms. Here is the Book model used by the rest of the page, with the ISBN and title validators, the class-based validator on the summary, a clean() hook, and two constraints (explained in Constraint validation):

catalog/models.py
from datetime import date

from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import F, Q
from django.db.models.functions import Lower

from .validators import MaxWordsValidator, validate_isbn13, validate_slugish_title


class Author(models.Model):
    name = models.CharField(max_length=100)


class Book(models.Model):
    title = models.CharField(max_length=200, validators=[validate_slugish_title])
    isbn = models.CharField(max_length=17, unique=True, validators=[validate_isbn13])
    summary = models.TextField(blank=True, validators=[MaxWordsValidator(50)])
    pages = models.PositiveIntegerField(validators=[MinValueValidator(1)])
    published = models.DateField(null=True, blank=True)
    discount_price = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
    price = models.DecimalField(max_digits=6, decimal_places=2)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.CheckConstraint(
                condition=Q(discount_price__isnull=True) | Q(discount_price__lt=F("price")),
                name="catalog_book_discount_below_price",
                violation_error_code="discount_too_high",
                violation_error_message="The discount price must be lower than the price.",
            ),
            models.UniqueConstraint(
                Lower("title"),
                "author",
                name="catalog_book_unique_title_per_author",
                violation_error_code="duplicate_title",
                violation_error_message="This author already has a book with that title.",
            ),
        ]

    def clean(self):
        if self.published and self.published > date.today():
            raise ValidationError({"published": "Publication date cannot be in the future."})

full_clean() and its four steps

Model.full_clean(exclude=None, validate_unique=True, validate_constraints=True) is the entry point. It calls, in this order:

  1. clean_fields(exclude): for each field, field.clean() (type coercion, blank/null rules, choices, then the field’s validators). Fields with blank=True and an empty value are skipped.

  2. clean(): your hook for rules that involve several fields. It runs even if clean_fields() failed, mirroring Form.clean().

  3. validate_unique(exclude): checks unique=True, unique_together and unique_for_date with database queries.

  4. validate_constraints(exclude): checks every constraint in Meta.constraints.

The two last steps skip fields that already failed in the earlier steps. All errors are gathered into one ValidationError whose message_dict is keyed by field, with __all__ for non-field errors. If you call the individual steps yourself, pass exclude as a set (Django 4.1 made full_clean() convert it to a set, and the docs prefer a set everywhere).

catalog/tests.py: full_clean() collecting errors
def test_full_clean_collects_errors(self):
    b = make_book(self.author, title="<b>", pages=0)
    with self.assertRaises(ValidationError) as cm:
        b.full_clean()
    self.assertEqual(set(cm.exception.message_dict), {"title", "pages"})

def test_model_clean_keyed_error(self):
    b = make_book(self.author, published=date.today() + timedelta(days=1))
    with self.assertRaises(ValidationError) as cm:
        b.full_clean()
    self.assertIn("published", cm.exception.message_dict)

Raising ValidationError({"published": …​}) from Model.clean() attaches the error to a field; a plain ValidationError("…​") becomes a non-field error.

save() does not validate

The most common surprise for newcomers: save() does not call full_clean(), and neither does objects.create(). Validators run only when something asks for validation (a ModelForm, the admin, a DRF serializer’s ModelSerializer for its own rules, or your own call). The database still enforces its constraints, so an invalid save raises IntegrityError rather than ValidationError:

catalog/tests.py: validators are not run on save, constraints are
def test_validators_do_not_run_on_save(self):
    a = Author.objects.create(name="A")
    make_book(a, isbn="not-an-isbn").save()           # no validation on save()
    with self.assertRaises(ValidationError):
        make_book(a, isbn="not-an-isbn", title="Other").full_clean()

def test_save_does_not_validate_constraints_but_db_does(self):
    b = make_book(self.author, discount_price=Decimal("12.00"))
    with self.assertRaises(IntegrityError), transaction.atomic():
        b.save()

For code paths that build instances from untrusted data outside forms (an import command, a webhook), call full_clean() explicitly before save(); the DRF equivalent is on Django REST Framework.

Constraint validation

A database constraint protects the data but reports failure as an IntegrityError, which a form cannot turn into a friendly message. Since Django 4.1, CheckConstraint, UniqueConstraint and the PostgreSQL ExclusionConstraint are also checked during model validation: Model.validate_constraints(exclude=None) calls each constraint’s validate() method, which evaluates the condition (with a query when it needs the database) and raises ValidationError if it is violated. Because ModelForm calls it, a violated constraint appears as a normal form error.

The constraint options that shape the error are violation_error_message and violation_error_code. A constraint that cannot be tied to one field produces a non-field error (__all__).

catalog/tests.py: constraint violations as ValidationError
def test_check_constraint_validation(self):
    b = make_book(self.author, discount_price=Decimal("12.00"))
    with self.assertRaises(ValidationError) as cm:
        b.full_clean()
    err = cm.exception.error_dict["__all__"][0]
    self.assertEqual(err.code, "discount_too_high")
    self.assertEqual(err.message, "The discount price must be lower than the price.")

def test_unique_constraint_functional(self):
    make_book(self.author).save()
    dup = make_book(self.author, isbn="978-1-4028-9462-6", title="DUNE")  # different case
    with self.assertRaises(ValidationError) as cm:
        dup.full_clean()
    self.assertEqual(cm.exception.error_dict["__all__"][0].code, "duplicate_title")

Several details matter in practice:

  • exclude skips constraints. validate() ignores a constraint when a field it needs is in exclude. This is why a form that omits price cannot check the discount rule (see How ModelForm joins the two layers), and why full_clean(exclude={"price"}) passes.

  • Constraints are checked last and only for fields that passed. A bad price is reported by the field; the check constraint is then skipped instead of reporting a second, confusing error.

  • You can run it alone. book.validate_constraints() raises without touching other rules, and full_clean(validate_constraints=False) leaves them out.

  • Validation is a read, not a lock. Like validate_unique(), it checks the state at that moment; a concurrent request can still violate the constraint before save(). The database remains the final arbiter, so handle IntegrityError in code that creates rows under contention (see Transactions, Raw SQL & Multiple Databases).

  • Custom constraints subclass BaseConstraint and must implement validate() along with the SQL methods.

  • Check constraints on a GeneratedField are supported by validation since Django 5.2.

catalog/tests.py: exclude, and running the step on its own
def test_exclude_skips_constraint(self):
    b = make_book(self.author, discount_price=Decimal("12.00"))
    b.full_clean(exclude={"price"})        # the constraint needs price, so it is ignored

def test_validate_constraints_directly(self):
    b = make_book(self.author, discount_price=Decimal("12.00"))
    with self.assertRaises(ValidationError):
        b.validate_constraints()
    b.full_clean(validate_constraints=False)

The constraints themselves (options, deferrable, partial and functional constraints, migrations) are covered on Constraints, Indexes & Composite Keys; this page only looks at how they report errors.

How ModelForm joins the two layers

A ModelForm runs the normal form cycle (The validation cycle) and then, right after Form.clean(), the model validation:

  1. The form’s cleaned values are copied onto the instance (construct_instance()).

  2. instance.full_clean(exclude=…​, validate_unique=False, validate_constraints=False) runs: field validators and Model.clean(), for the model fields that are in the form and passed form validation.

  3. validate_unique() and then validate_constraints() run, with the same exclusions.

Errors from all steps are merged into the form’s errors, so a view only ever inspects the form. Two consequences:

  • Fields left out of Meta.fields are not validated at all, and constraints that involve them are skipped. Keep the model’s constraints enforceable by the database, and validate such rules in the code that sets those fields.

  • The unique and constraint steps are switched on by ModelForm.clean(). If you override clean() and forget super().clean(), uniqueness and constraints are silently not validated, and the first sign of the bug is an IntegrityError on save.

catalog/forms.py: a ModelForm that keeps super().clean()
class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "isbn", "summary", "pages", "published",
                  "price", "discount_price", "author"]

    def clean(self):
        cleaned_data = super().clean()   # keep this: it enables unique and constraint validation
        price = cleaned_data.get("price")
        discount = cleaned_data.get("discount_price")
        if price is not None and discount is not None and discount >= price:
            self.add_error("discount_price", "Must be below the price.")
        return cleaned_data


class BookNoSuperForm(BookForm):
    """Pitfall: overriding clean() without calling super() skips uniqueness checks."""

    def clean(self):
        return self.cleaned_data
catalog/tests.py: the pitfall, and a form that omits price
def test_no_super_skips_unique_checks(self):
    make_book(self.author).save()
    ok = BookNoSuperForm(self.data(title="Other"))     # the ISBN duplicates an existing book
    self.assertTrue(ok.is_valid())                      # validation did not notice...
    with self.assertRaises(IntegrityError), transaction.atomic():
        ok.save()                                       # ...the database did
    good = BookForm(self.data(title="Other"))
    self.assertFalse(good.is_valid())
    self.assertIn("isbn", good.errors)

def test_form_catches_unique_and_constraint(self):
    make_book(self.author).save()
    f = BookForm(self.data(title="dune", isbn="978-1-4028-9462-6"))
    self.assertFalse(f.is_valid())
    self.assertTrue(f.has_error("__all__", "duplicate_title"))

Customising messages

Error messages can be set at three levels. A message on the form field or in the form’s Meta.error_messages always wins over one on the model field, and model-level messages are used only when the error is raised during the model validation step.

catalog/forms.py: overriding messages in Meta.error_messages
class BookFriendlyMessagesForm(BookForm):
    class Meta(BookForm.Meta):
        error_messages = {
            "isbn": {"unique": "We already stock a book with this ISBN."},
            "pages": {"required": "Please tell us how many pages the book has."},
        }

Using it in a view

The view below is the usual GET/POST/redirect pattern with one change: when validation fails it re-renders with status 422 (Unprocessable Content) so that clients and tests can tell a validation failure from a successful render. The template prints {{ form.non_field_errors }} and each field.errors; {% csrf_token %} is mandatory for the POST.

catalog/views.py
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods

from .forms import BookForm


@require_http_methods(["GET", "POST"])
def book_create(request):
    form = BookForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("book-created")
    status = 422 if form.errors else 200
    return render(request, "catalog/book_form.html", {"form": form}, status=status)

Posting an invalid book with curl (the CSRF cookie is fetched first and sent back as the X-CSRFToken header) returns the form with the errors:

Posting invalid data to the development server
$ curl -s -c jar http://127.0.0.1:8134/books/new/ -o /dev/null
$ curl -s -b jar -w 'HTTP %{http_code}\n' -X POST http://127.0.0.1:8134/books/new/ \
    -H "X-CSRFToken: $(awk '/csrftoken/ {print $7}' jar)" \
    --data "title=Dune&isbn=978-0-306-40615-8&pages=0&price=10&discount_price=12&author=1" \
  | grep -o '<li>[^<]*</li>\|HTTP.*'
<li>978-0-306-40615-8 has a wrong ISBN-13 check digit.</li>
<li>Ensure this value is greater than or equal to 1.</li>
<li>Must be below the price.</li>
HTTP 422

The same flow is covered by a test client test (self.client.post("/books/new/", …​) returns 422 with the message, and a valid post redirects). Class-based equivalents (FormView, CreateView) call the same is_valid(); see Class-Based Views and Testing.

Sync and async

Form and model validation are synchronous. Field and Form.clean() code is pure Python, but ModelForm validation runs validate_unique() and validate_constraints(), which query the database, so calling form.is_valid() directly on the event loop raises SynchronousOnlyOperation. In an async def view, run it in a thread:

catalog/tests.py: ModelForm validation from async code
async def test_model_form_is_valid_needs_a_thread(self):
    from asgiref.sync import sync_to_async
    from django.core.exceptions import SynchronousOnlyOperation

    author = await Author.objects.acreate(name="Async")
    data = {"title": "Dune", "isbn": GOOD_ISBN, "summary": "", "pages": "1", "published": "",
            "price": "10.00", "discount_price": "", "author": str(author.pk)}
    with self.assertRaises(SynchronousOnlyOperation):
        BookForm(data).is_valid()
    form = BookForm(data)
    self.assertTrue(await sync_to_async(form.is_valid)())

Pitfalls

  • A clean_<fieldname>() that does not return the value turns the field into None in cleaned_data.

  • Form.clean() runs even when fields failed: cleaned_data["x"] can raise KeyError; use .get().

  • Calling add_error() removes the field from cleaned_data; do not read it afterwards.

  • Do not put a database-wide rule only in a validator: save(), bulk_create() and update() bypass validators. Pair important rules with a constraint.

  • bulk_create(), QuerySet.update() and fixtures loaded with loaddata skip model validation entirely.

  • Overriding ModelForm.clean() without super().clean() disables unique and constraint validation.

  • Reusing a model instance after a failed ModelForm validation is not recommended; the form has already modified it.

  • FileExtensionValidator and similar name-based checks are not security controls; validate content separately.

  • Do not turn validators into authorisation: they check shape, not who is allowed to do what (see Security).

Where the books are outdated

The four reference books were written for Django 1.11 and 2.x, and the validation machinery they describe (clean(), clean_<field>(), ValidationError, validators) is still correct. What has changed is around it:

  • Older books and tutorials teach CheckConstraint(check=…​). The argument was deprecated in favour of condition in Django 5.1 and removed in 6.0, so such code raises TypeError today.

  • Constraints did not take part in validation until Django 4.1; in older material a ModelForm lets a violated constraint through to an IntegrityError (and unique_together was the only multi-field rule validated). Constraints did not exist at all before 2.2.

  • ugettext and ugettext_lazy, common in old validator examples, were removed in Django 4.0; use gettext and gettext_lazy.

  • Old validator code uses whitelist on EmailValidator; it was deprecated in 3.2 and removed in 4.1 in favour of allowlist.

  • Class validators in old examples often omit @deconstructible or __eq__, which makes makemigrations produce a new migration for the same field repeatedly; see Validators: reusable rules.

  • The books' form-rendering advice pre-dates template-based rendering; error lists are rendered with templates since Django 4.0.

What changed recently

Version Change

3.2

Built-in validators include the value in params, so messages can use %(value)s; ValidationError equality ignores message and params ordering.

4.0

ErrorList (and forms and formsets) render through the template engine, so error markup can be overridden with templates.

4.1

Constraints (CheckConstraint, UniqueConstraint, ExclusionConstraint) are validated by full_clean() and ModelForm; custom constraints must implement validate(); StepValueValidator added; full_clean() converts exclude to a set.

5.0

violation_error_code added to BaseConstraint, CheckConstraint, UniqueConstraint (and ExclusionConstraint); passing name and violation_error_message to BaseConstraint positionally is deprecated in favour of keyword-only arguments.

5.1

DomainNameValidator and validate_domain_name added; CheckConstraint(check=…​) deprecated in favour of condition.

5.2

UniqueConstraint.violation_error_code and violation_error_message are always used when provided (before, they were ignored for a plain fields= constraint); validation of constraints on a GeneratedField; ErrorList gets a field_id argument and BoundField an aria_describedby property for screen readers.

6.0

The check argument of CheckConstraint is removed.

6.1

Validation errors in the admin change form are shown after the help text and before the field input. BinaryField validates Base64 input strictly, raising ValidationError for invalid data.