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 ( |
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 |
One callable works on form fields and model fields alike. |
Rule about one field, specific to this form |
|
Runs after the field’s own checks; returns the cleaned value. |
Rule about several fields of a form |
|
Runs once every field has been cleaned, even when some failed. |
Rule about the data, whatever the entry point (form, admin, API, shell) |
|
Runs from |
Rule the database must guarantee (unique, check) |
|
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.
The following points follow from the documented behaviour:
-
If
to_python(),validate()orrun_validators()raises, the field’s error is recorded and itsclean_<fieldname>()is not called, but the remaining fields are still cleaned. -
Form.clean()runs whether or not earlier steps raised, socleaned_datamay lack fields that failed; always use.get(). -
The return value of
clean_<fieldname>()replaces the value incleaned_data, so it must return the value (changed or not). Aclean_<fieldname>()that forgets to return putsNoneincleaned_data. -
Field.clean()stops at the first stage that raises, butrun_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():
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()
Source: Form and field validation.
A test pins the order, and also shows that a field error skips clean_recipients() but not clean():
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)
$ 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
Source: Form field default cleaning.
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.
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"),
]
)
Source: Raising ValidationError and ValidationError.
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.
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:
>>> 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.
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},
)
Source: Writing validators.
The same function is used on a plain form field and, later on this page, on the Book model field:
class IsbnLookupForm(forms.Form):
isbn = forms.CharField(validators=[validate_isbn13])
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."])
Source: How validators are run.
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.
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
)
Source: Writing validators and Serializing values (migrations).
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 |
|---|---|---|
|
|
|
|
Looks like an email address; values over 320 characters are invalid; domains without a dot need the |
|
|
A URL with a host; default schemes |
|
|
A domain name (IDNA accepted by default); IP addresses are rejected. |
|
|
Letters, digits, underscores and hyphens (the Unicode variant allows Unicode letters). |
|
|
IP address syntax. |
|
|
Value bounds; the limit may be a callable. |
|
|
Length bounds; the limit may be a callable. |
|
|
Value is an integral multiple of the step (plus optional offset). |
|
|
Digit counts of a |
|
|
Case-insensitive extension of |
|
|
No |
|
RegexValidator is the quickest way to build a one-off rule and, being a validator instance, works in both worlds:
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):
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."})
Source: Validating objects and Field.validators.
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:
-
clean_fields(exclude): for each field,field.clean()(type coercion,blank/nullrules,choices, then the field’s validators). Fields withblank=Trueand an empty value are skipped. -
clean(): your hook for rules that involve several fields. It runs even ifclean_fields()failed, mirroringForm.clean(). -
validate_unique(exclude): checksunique=True,unique_togetherandunique_for_datewith database queries. -
validate_constraints(exclude): checks every constraint inMeta.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).
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)
Source: Model.full_clean() and Model.clean().
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:
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()
Source: How validators are run and Validating objects.
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__).
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")
Source: Constraints reference.
Several details matter in practice:
-
excludeskips constraints.validate()ignores a constraint when a field it needs is inexclude. This is why a form that omitspricecannot check the discount rule (see How ModelForm joins the two layers), and whyfull_clean(exclude={"price"})passes. -
Constraints are checked last and only for fields that passed. A bad
priceis 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, andfull_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 beforesave(). The database remains the final arbiter, so handleIntegrityErrorin code that creates rows under contention (see Transactions, Raw SQL & Multiple Databases). -
Custom constraints subclass
BaseConstraintand must implementvalidate()along with the SQL methods. -
Check constraints on a
GeneratedFieldare supported by validation since Django 5.2.
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)
Source: BaseConstraint.validate() and Model.validate_constraints().
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:
-
The form’s cleaned values are copied onto the instance (
construct_instance()). -
instance.full_clean(exclude=…, validate_unique=False, validate_constraints=False)runs: field validators andModel.clean(), for the model fields that are in the form and passed form validation. -
validate_unique()and thenvalidate_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.fieldsare 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 overrideclean()and forgetsuper().clean(), uniqueness and constraints are silently not validated, and the first sign of the bug is anIntegrityErroron save.
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
Source: Overriding the clean() method and Validation on a ModelForm.
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.
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.
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)
Source: Working with forms: the view.
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:
$ 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:
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)())
Source: Asynchronous support: async-safety.
Pitfalls
-
A
clean_<fieldname>()that does notreturnthe value turns the field intoNoneincleaned_data. -
Form.clean()runs even when fields failed:cleaned_data["x"]can raiseKeyError; use.get(). -
Calling
add_error()removes the field fromcleaned_data; do not read it afterwards. -
Do not put a database-wide rule only in a validator:
save(),bulk_create()andupdate()bypass validators. Pair important rules with a constraint. -
bulk_create(),QuerySet.update()and fixtures loaded withloaddataskip model validation entirely. -
Overriding
ModelForm.clean()withoutsuper().clean()disables unique and constraint validation. -
Reusing a model instance after a failed
ModelFormvalidation is not recommended; the form has already modified it. -
FileExtensionValidatorand 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 ofconditionin Django 5.1 and removed in 6.0, so such code raisesTypeErrortoday. -
Constraints did not take part in validation until Django 4.1; in older material a
ModelFormlets a violated constraint through to anIntegrityError(andunique_togetherwas the only multi-field rule validated). Constraints did not exist at all before 2.2. -
ugettextandugettext_lazy, common in old validator examples, were removed in Django 4.0; usegettextandgettext_lazy. -
Old validator code uses
whitelistonEmailValidator; it was deprecated in 3.2 and removed in 4.1 in favour ofallowlist. -
Class validators in old examples often omit
@deconstructibleor__eq__, which makesmakemigrationsproduce 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 |
4.0 |
|
4.1 |
Constraints ( |
5.0 |
|
5.1 |
|
5.2 |
|
6.0 |
The |
6.1 |
Validation errors in the admin change form are shown after the help text and before the field input. |