Django Module 5 – Forms, validation and file uploads
Validation: validators, clean() and model rules
Write reusable Django validators and clean() methods, follow the order validation runs in, and decide when a rule belongs in a form, a model or the database.
What you will learn
- Write reusable validators that raise ValidationError with a code and params
- Predict the order in which Django runs to_python(), the validators, clean_<field>() and clean()
- Decide whether a rule belongs in a form, a model or a database constraint
- Write error messages that tell people how to fix a value and reveal nothing internal
Before you start
What you’ll learn · Before you start
What you will learn
- Write reusable validators that raise ValidationError with a code and params
- Predict the order in which Django runs to_python(), the validators, clean_<field>() and clean()
- Decide whether a rule belongs in a form, a model or a database constraint
- Write error messages that tell people how to fix a value and reveal nothing internal
Before you start
On this page
A clubhouse booking of Nivas has four rules: a real flat, an end after the start, at most 4 hours, and at most 10 guests. The recorded run below sends two bad bookings down five ways of writing one: the booking that ends before it starts was refused on every path it took, because the database checks that rule too, while the one of 6 hours was saved twice.
Every rule can be written in several places: a validator, a form method, the model, the database. This lesson writes the validators of Nivas, follows the fixed order in which Django runs every check of a form, shows which checks each way of saving a model runs and which it skips, and ends with a table that decides where a rule belongs, and with error messages that tell a resident how to fix the value.
A validator is a function that raises ValidationError with a code
A validator: A callable that receives one value and raises ValidationError when the value breaks a rule, such as validate_flat_code. Because it sees only the value, the same validator can guard model fields and form fields. takes one value and raises
ValidationError: The exception Django's validation raises and collects. It carries a message for people, a code for programs that does not change with translation, and params that fill the placeholders of the message. when the value breaks a rule; it returns nothing when the value is
fine. Nivas has two. validate_flat_code is a plain function: a block letter from A to F, a hyphen and a three- or
four-digit number. validate_ifsc is an instance of Django’s RegexValidator class, made with the pattern of a bank
branch code:
amenities/validators.py · nivas/amenities/validators.py
import re
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
FLAT_CODE = re.compile(r'[A-F]-[0-9]{3,4}')
def validate_flat_code(value):
"""A flat code is a block letter from A to F, a hyphen and a flat number of three or four digits."""
if not FLAT_CODE.fullmatch(value):
raise ValidationError(
'"%(value)s" is not a flat code. Write the block, a hyphen and the number, such as B-1203.',
code='flat_code',
params={'value': value},
)
# An IFSC: four letters for the bank, a zero, then six letters or digits for the branch.
validate_ifsc = RegexValidator(
r'\A[A-Z]{4}0[A-Z0-9]{6}\Z',
message='Enter the 11 characters of the IFSC, such as ABCD0123456.',
code='ifsc',
) amenities/models.py · nivas/amenities/models.py
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from .validators import validate_flat_code, validate_ifsc
LONGEST_BOOKING = timedelta(hours=4)
class Flat(models.Model):
code = models.CharField(max_length=6, unique=True, validators=[validate_flat_code])
def __str__(self):
return self.code
class Booking(models.Model):
"""A booking of the clubhouse."""
flat = models.ForeignKey(Flat, on_delete=models.CASCADE)
starts_at = models.DateTimeField()
ends_at = models.DateTimeField()
guests = models.PositiveSmallIntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
refund_ifsc = models.CharField(max_length=11, blank=True, validators=[validate_ifsc])
class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(ends_at__gt=models.F('starts_at')),
name='booking_ends_after_start',
violation_error_code='ends_before_start',
violation_error_message='A booking must end after it starts.',
),
]
def clean(self):
# A rule of the society, not a fact of the data: only full_clean() runs it.
if self.starts_at and self.ends_at and self.ends_at - self.starts_at > LONGEST_BOOKING:
raise ValidationError(
{'ends_at': ValidationError('A booking can last at most %(hours)s hours.', code='too_long', params={'hours': 4})}
) The IFSC of a bank branch
The Indian Financial System Code names one bank branch for transfers in India. The Reserve Bank of India describes it as 11 characters: four letters for the bank, then the digit 0, then six characters for the branch.
This program calls both validators on good and bad values, then shows the IFSC pattern without its anchors, then gives one validator to a model field and to a form field:
"""Call the two validators of Nivas on good and bad values, then the IFSC pattern without its anchors, then one
validator in two places: on a model field and on a form field.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django import forms # noqa: E402
from django.core.exceptions import ValidationError # noqa: E402
from django.core.validators import RegexValidator # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
from amenities.models import Flat # noqa: E402
from amenities.validators import validate_flat_code, validate_ifsc # noqa: E402
def outcome(validator, value):
try:
validator(value)
except ValidationError as error:
return f'{error.code}: {error.messages[0]}'
return 'passes'
for name, validator, values in [('validate_flat_code', validate_flat_code, ['B-1203', 'G-1203', 'b-1203', 'B-12']),
('validate_ifsc', validate_ifsc, ['ABCD0123456', 'ABCD1123456', 'ABCD012345'])]:
print(name)
for value in values:
print(f' {value!r:15} {outcome(validator, value)}')
try:
validate_flat_code('G-1203')
except ValidationError as error:
print(f' the error of G-1203: code={error.code!r}, params={error.params!r}')
bare = RegexValidator(r'\A[A-Z]{4}0[A-Z0-9]{6}\Z')
print("The same pattern with Django's default message and code")
print(f" {'ABCD1123456'!r:15} {outcome(bare, 'ABCD1123456')}")
loose = RegexValidator(r'[A-Z]{4}0[A-Z0-9]{6}', message='Enter the 11 characters of the IFSC.', code='ifsc')
print('The IFSC pattern without \\A and \\Z')
for value in ['ABCD0123456', 'refund to ABCD0123456 please', 'ABCD012345678']:
print(f' {value!r:30} {outcome(loose, value)}')
print('validate_flat_code on the model field and on a form field')
try:
Flat(code='G-12').full_clean()
except ValidationError as error:
print(' Flat(code="G-12").full_clean():', error.message_dict)
class FlatForm(forms.Form):
code = forms.CharField(validators=[validate_flat_code])
form = FlatForm({'code': 'G-12'})
print(' FlatForm({"code": "G-12"}).errors:', dict(form.errors)) Output
validate_flat_code
'B-1203' passes
'G-1203' flat_code: "G-1203" is not a flat code. Write the block, a hyphen and the number, such as B-1203.
'b-1203' flat_code: "b-1203" is not a flat code. Write the block, a hyphen and the number, such as B-1203.
'B-12' flat_code: "B-12" is not a flat code. Write the block, a hyphen and the number, such as B-1203.
validate_ifsc
'ABCD0123456' passes
'ABCD1123456' ifsc: Enter the 11 characters of the IFSC, such as ABCD0123456.
'ABCD012345' ifsc: Enter the 11 characters of the IFSC, such as ABCD0123456.
the error of G-1203: code='flat_code', params={'value': 'G-1203'}
The same pattern with Django's default message and code
'ABCD1123456' invalid: Enter a valid value.
The IFSC pattern without \A and \Z
'ABCD0123456' passes
'refund to ABCD0123456 please' passes
'ABCD012345678' passes
validate_flat_code on the model field and on a form field
Flat(code="G-12").full_clean(): {'code': ['"G-12" is not a flat code. Write the block, a hyphen and the number, such as B-1203.']}
FlatForm({"code": "G-12"}).errors: {'code': ['"G-12" is not a flat code. Write the block, a hyphen and the number, such as B-1203.']}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_validators.py
Three things from the run. First, an error carries more than its message: code='flat_code', which a test or an
API can compare however the site is translated, and params={'value': 'G-1203'}, which fills %(value)s in the
message; Django’s documentation asks for both, and for placeholders instead of text pasted into the message. Second,
RegexValidator looks for its pattern with re.search(), anywhere in the value. Without \A and \Z the pattern
accepted 'refund to ABCD0123456 please' and a 13-character code; with them, the whole value must match. Third, the
same function guarded Flat.code and a form field, and both reported the same message, because a validator receives
the value only, never the form or the model.
A pattern without anchors
RegexValidator(r'[A-Z]{4}0[A-Z0-9]{6}') passes any value that contains an IFSC somewhere. Anchor it with \A and
\Z, or write a function that uses re.fullmatch(), as validate_flat_code does.
b-1203 failed too: the validator wants capitals. Whether a resident may type small letters is a question of the
step in which the value is converted, which the next section answers.
Checkpoint
Check: Validators, codes and params
Django runs the checks of a form in a fixed order
Each field of a bound form is cleaned in three steps of its own, then the form’s method for that field, and the form’s
clean() runs last. This program records the steps of the flat field and the form’s two methods for five bookings;
BookingForm capitalises the flat code in its field’s to_python() and turns it into a Flat row in clean_flat():
from django import forms
from django.core.exceptions import ValidationError
from .models import LONGEST_BOOKING, Flat
from .validators import validate_flat_code
class FlatCodeField(forms.CharField):
"""A CharField that writes the flat code in capitals while converting it, before any validator runs."""
def to_python(self, value):
return super().to_python(value).upper()
class BookingForm(forms.Form):
flat = FlatCodeField(max_length=6, validators=[validate_flat_code])
starts_at = forms.DateTimeField()
ends_at = forms.DateTimeField()
guests = forms.IntegerField(min_value=1, max_value=10)
def clean_flat(self):
# Runs only when the field itself passed. Its return value replaces cleaned_data['flat']: here the Flat row.
code = self.cleaned_data['flat']
try:
return Flat.objects.get(code=code)
except Flat.DoesNotExist:
raise ValidationError('There is no flat %(code)s in Nivas.', code='unknown_flat', params={'code': code})
def clean(self):
# Runs after every field, whether they passed or not, so it reads with get().
cleaned = super().clean()
starts, ends = cleaned.get('starts_at'), cleaned.get('ends_at')
if starts and ends:
if ends <= starts:
self.add_error('ends_at', ValidationError('Choose an end time after the start time.', code='ends_before_start'))
elif ends - starts > LONGEST_BOOKING:
self.add_error('ends_at', ValidationError('Book at most %(hours)s hours at a time.', code='too_long', params={'hours': 4}))
return cleaned """Record the order in which Django cleans BookingForm: the flat field's to_python(), validate() and
run_validators(), then clean_flat(), the other fields, and the form's clean(). Then a form that capitalises the flat
in clean_flat(), one step too late.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
from django import forms # noqa: E402
from amenities.forms import BookingForm, FlatCodeField # noqa: E402
from amenities.models import Flat # noqa: E402
from amenities.validators import validate_flat_code # noqa: E402
for code in ['A-101', 'B-1203', 'C-901']:
Flat.objects.create(code=code)
steps = []
class TracedFlatField(FlatCodeField):
def to_python(self, value):
steps.append('flat.to_python')
return super().to_python(value)
def validate(self, value):
steps.append('flat.validate')
super().validate(value)
def run_validators(self, value):
steps.append('flat.run_validators')
super().run_validators(value)
class TracedBookingForm(BookingForm):
flat = TracedFlatField(max_length=6, validators=[validate_flat_code])
def clean_flat(self):
steps.append('clean_flat')
return super().clean_flat()
def clean(self):
steps.append('clean')
return super().clean()
def book(title, **data):
steps.clear()
form = TracedBookingForm({'starts_at': '2026-11-14 18:00', 'ends_at': '2026-11-14 21:00', 'guests': '6', **data})
valid = form.is_valid()
print(title)
print(' steps:', ' > '.join(steps))
print(' is_valid():', valid, '| cleaned flat:', repr(form.cleaned_data.get('flat')))
for name, errors in form.errors.as_data().items():
print(f' errors[{name!r}]: {errors[0].code}: {form.errors[name][0]}')
book('1. flat=b-1203, 18:00 to 21:00', flat='b-1203')
book('2. flat=G-1203, a block Nivas does not have', flat='G-1203')
book('3. flat=D-404, a flat code no flat has', flat='D-404')
book('4. flat=C-901, ends at 17:00, before it starts', flat='C-901', ends_at='2026-11-14 17:00')
class LateCapitalsForm(forms.Form):
flat = forms.CharField(max_length=6, validators=[validate_flat_code])
def clean_flat(self):
return self.cleaned_data['flat'].upper() # too late: the validator has already refused b-1203
form = LateCapitalsForm({'flat': 'b-1203'})
print('5. flat=b-1203, capitalised in clean_flat() instead of to_python()')
print(' is_valid():', form.is_valid(), '| errors:', form.errors.as_data()['flat'][0].code) Output
1. flat=b-1203, 18:00 to 21:00 steps: flat.to_python > flat.validate > flat.run_validators > clean_flat > clean is_valid(): True | cleaned flat: <Flat: B-1203> 2. flat=G-1203, a block Nivas does not have steps: flat.to_python > flat.validate > flat.run_validators > clean is_valid(): False | cleaned flat: None errors['flat']: flat_code: "G-1203" is not a flat code. Write the block, a hyphen and the number, such as B-1203. 3. flat=D-404, a flat code no flat has steps: flat.to_python > flat.validate > flat.run_validators > clean_flat > clean is_valid(): False | cleaned flat: None errors['flat']: unknown_flat: There is no flat D-404 in Nivas. 4. flat=C-901, ends at 17:00, before it starts steps: flat.to_python > flat.validate > flat.run_validators > clean_flat > clean is_valid(): False | cleaned flat: <Flat: C-901> errors['ends_at']: ends_before_start: Choose an end time after the start time. 5. flat=b-1203, capitalised in clean_flat() instead of to_python() is_valid(): False | errors: flat_code
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cleaning_order.py
Step through the same five bookings and watch where each one stops:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- Conversion first. Booking 1 sends flat=b-1203. FlatCodeField.to_python() strips the text and writes it in capitals, so every later step sees B-1203.
- The required check. validate() makes the checks built into the field class: for a CharField, that a required value is not empty. B-1203 is not.
- The validators. run_validators() calls every validator of the field and collects their errors: the max_length of 6 and validate_flat_code. B-1203 passes both.
- clean_flat(). Only then does the form's clean_flat() run. It looks the code up and returns the Flat row, which replaces the text in cleaned_data.
- The other fields. starts_at, ends_at and guests go through the same steps, one field at a time, in the order the form declares them.
- The form's clean(). Last, clean() compares fields: the booking runs from 18:00 to 21:00, so it ends after it starts and lasts 3 hours. is_valid() returns True.
- A validator refuses. Booking 2 sends G-1203. Nivas has no block G, so validate_flat_code raises flat_code. A field that failed never reaches its clean_flat().
- clean() still runs. The form's clean() runs even so, which is why it reads cleaned_data with get(): the flat has no entry there.
- A rule that needs the data. Booking 3 sends D-404, a well-formed code that no flat has. Every field check passes; clean_flat() finds no row and raises unknown_flat.
- A rule across fields. Booking 4 ends at 17:00, before it starts. Each field is fine alone; clean() compares them and calls add_error('ends_at', …), which also removes ends_at from cleaned_data.
| Step | What it is for | Booking from the run |
|---|---|---|
to_python() |
convert the text: capitals, a time, a number | b-1203 became B-1203 |
validate() |
the field class’s own checks, such as required | every booking passed |
run_validators() |
every validator of the field | G-1203 failed with flat_code |
clean_flat() |
a rule that needs data; returns the cleaned value | D-404 failed with unknown_flat |
clean() |
rules across fields, reported with add_error() |
17:00 failed with ends_before_start |
Two rules of the order matter in practice. A field that fails never reaches its clean_<field>(), so booking 2 has
no clean_flat step, while the form’s clean() runs for every booking and must read cleaned_data with get().
And a step can only help the steps after it: booking 5 capitalised the code in clean_flat(), after the validator had
already refused b-1203. Convert in to_python(), check in validators, look things up in clean_<field>(), compare
fields in clean().
Key idea
Each field converts, then checks, then runs its validators, then its clean_<field>(); the form’s clean() comes
last and runs even when fields failed.
Checkpoint
Check: The order Django cleans a form in
A model is validated only when code asks, never by save()
A model has its own validation, and the constraints lesson showed one part of it: full_clean(): The model method that validates an object in four steps: its field validators, Model.clean(), the uniqueness checks and Meta.constraints. ModelForms call it; save(), update() and bulk_create() never do.
reports a broken constraint before the database sees the row. It runs four steps in order: the field validators
(clean_fields()), Model.clean() for rules of the whole object, validate_unique() and validate_constraints().
Booking.clean() holds a rule that is in no constraint, at most 4 hours. This program sends a booking that ends
before it starts, then one of 6 hours, down every way of writing one:
"""Send two bad bookings down every path that writes a booking: the form, Model.full_clean(), save(), update() and
bulk_create(). One breaks a rule the database enforces, the other a rule only Model.clean() knows.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import os
from datetime import datetime, timedelta, timezone
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
from django.core.exceptions import ValidationError # noqa: E402
from django.db import IntegrityError, transaction # noqa: E402
from amenities.forms import BookingForm # noqa: E402
from amenities.models import Booking, Flat # noqa: E402
flat = Flat.objects.create(code='B-1203')
six_pm = datetime(2026, 11, 14, 18, 0, tzinfo=timezone.utc)
def attempt(label, write):
try:
with transaction.atomic():
result = write()
except ValidationError as error:
print(f' {label}: ValidationError {error.message_dict}')
except IntegrityError as error:
print(f' {label}: IntegrityError: {error}')
else:
print(f' {label}: {result}')
print('A booking that ends at 17:00, before it starts at 18:00')
form = BookingForm({'flat': 'B-1203', 'starts_at': '2026-11-14 18:00', 'ends_at': '2026-11-14 17:00', 'guests': '6'})
form.is_valid()
print(f" BookingForm: is_valid() False, errors['ends_at']: {form.errors['ends_at'][0]}")
early = dict(flat=flat, starts_at=six_pm, ends_at=six_pm - timedelta(hours=1), guests=6)
attempt('full_clean()', lambda: Booking(**early).full_clean())
attempt('save()', lambda: Booking(**early).save())
good = Booking.objects.create(flat=flat, starts_at=six_pm, ends_at=six_pm + timedelta(hours=2), guests=6)
attempt('update()', lambda: Booking.objects.filter(pk=good.pk).update(ends_at=six_pm - timedelta(hours=1)))
print('A booking of 6 hours, against the society rule of at most 4')
form = BookingForm({'flat': 'B-1203', 'starts_at': '2026-11-14 18:00', 'ends_at': '2026-11-15 00:00', 'guests': '6'})
form.is_valid()
print(f" BookingForm: is_valid() False, errors['ends_at']: {form.errors['ends_at'][0]}")
long = dict(flat=flat, starts_at=six_pm, ends_at=six_pm + timedelta(hours=6), guests=6)
attempt('full_clean()', lambda: Booking(**long).full_clean())
attempt('save()', lambda: f'saved as booking {Booking.objects.create(**long).pk}')
attempt('bulk_create()', lambda: f'saved {len(Booking.objects.bulk_create([Booking(**long)]))} booking')
print(' bookings longer than 4 hours in the table:',
sum(b.ends_at - b.starts_at > timedelta(hours=4) for b in Booking.objects.all())) Output
A booking that ends at 17:00, before it starts at 18:00
BookingForm: is_valid() False, errors['ends_at']: Choose an end time after the start time.
full_clean(): ValidationError {'__all__': ['A booking must end after it starts.']}
save(): IntegrityError: CHECK constraint failed: booking_ends_after_start
update(): IntegrityError: CHECK constraint failed: booking_ends_after_start
A booking of 6 hours, against the society rule of at most 4
BookingForm: is_valid() False, errors['ends_at']: Book at most 4 hours at a time.
full_clean(): ValidationError {'ends_at': ['A booking can last at most 4 hours.']}
save(): saved as booking 2
bulk_create(): saved 1 booking
bookings longer than 4 hours in the table: 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 where_rules_live.py
| Way of writing | Runs | Ends before it starts | Lasts 6 hours |
|---|---|---|---|
BookingForm.is_valid() |
the form’s fields and clean() |
refused at ends_at |
refused at ends_at |
full_clean() |
the four model steps | refused, under __all__ |
refused at ends_at |
save() |
nothing in Python | IntegrityError |
saved |
update() |
nothing in Python | IntegrityError |
not tried |
bulk_create() |
nothing in Python | not tried | saved |
Django’s documentation says it plainly: full_clean() is not called when you call save(), and validators do not run
when a model is saved. A ModelForm, the subject of the next lesson, calls full_clean() for the fields it includes,
and so does the admin, which builds its forms the same way. update() becomes one SQL statement and bulk_create()
inserts the objects it is given in as few queries as it can; neither calls save(), let alone full_clean(). Only the
CheckConstraint held on every path the first booking took, because the database checks every row that reaches it;
the 4-hour rule held only in the form and where full_clean() ran, and the run ended with 2 bookings longer than 4
hours in the table.
A rule in Model.clean() alone
Model.clean() feels like part of the model, but it runs only inside full_clean(). A view that builds a booking
and calls save(), a script, an import with bulk_create(): none of them runs it. Call full_clean() before
saving where no ModelForm does it, and put every rule that must always hold into the database too.
Checkpoint
Check: full_clean() and the writes that skip it
Each rule belongs where every write meets it
The run of the previous section gives the method: put a rule where every path that writes the data meets it, and in the form as well when a resident should see a friendly message at the field. Three questions about a rule decide most cases: can rows arrive without this form, does the rule compare a row with other rows, and does it depend on who is asking? Answer them for a rule of your own, or for “at most 4 hours”, “one booking per flat each evening” and “more than 10 guests only for the committee”:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Place | A message at the field | ModelForms, admin, full_clean | save(), update(), bulk_create | Rules about other rows | Rules about who is asking |
|---|---|---|---|---|---|
| A form field validator Sees: one submitted value | Good shown at its field: flat_code for G-1203 | Poor only this form runs it; the admin builds a form of its own | Poor code that writes without the form never runs it | Poor it is called with one value and nothing else | Poor it is called with the value alone |
| The form's clean() Sees: every field of one submission, and what the view passes in | Good add_error() puts it at a field: ends_before_start | Poor only this form runs it | Poor code that writes without the form never runs it | Mixed it can query, but two requests can both pass the query | Good the view can pass the user to the form |
| A model field validator Sees: one value of the row | Good a ModelForm shows it at the field | Good full_clean() runs it, and every ModelForm calls full_clean() | Poor save() runs no validator, as the docs warn | Poor it is called with one value and nothing else | Poor a model never sees the request |
| Model.clean() Sees: every field of one row | Good a dict ValidationError puts it at ends_at | Good full_clean() refused 6 hours with too_long | Poor save() and bulk_create() stored the 6-hour booking | Mixed it can query, but two saves can both pass the query | Poor a model never sees the request |
| A database constraint Sees: every row the table holds | Mixed full_clean() reports it for the whole row, under __all__ | Good validate_constraints() checks it in full_clean() | Good IntegrityError on save() and on update() | Good unique constraints compare rows, and the database settles races | Poor the database never sees the request |
How the answers decide
- “Can rows be written without this form, by the admin, a script, an import or an API?” Yes, other code writes them too and “Does the rule depend on who is asking, such as more than 10 guests only for the committee?” No, it is the same for everyone: rules out A form field validator and The form's clean(). Code that writes without the form never runs its checks; the recorded update() and bulk_create() went straight to the table.
- “Can rows be written without this form, by the admin, a script, an import or an API?” Yes, other code writes them too and “Does the rule depend on who is asking, such as more than 10 guests only for the committee?” Yes, it depends on the user: rules out A form field validator and adds a caveat to The form's clean(). A rule about the user lives where the request is: in this form, and again in the permission check of every other path that writes.
- “Can rows be written without this form, by the admin, a script, an import or an API?” Yes, other code writes them too: adds a caveat to A model field validator and Model.clean(). Only code that calls full_clean() runs them: save() and bulk_create() stored a 6-hour booking that Model.clean() refuses.
- “Does the rule compare the row with other rows, such as one booking per flat each evening?” Yes, it needs other rows: rules out A form field validator and A model field validator. A field validator is called with one value, so it cannot see other rows.
- “Does the rule compare the row with other rows, such as one booking per flat each evening?” Yes, it needs other rows: adds a caveat to The form's clean() and Model.clean(). A query in clean() can pass for two requests at the same moment; back the rule with a constraint in the database.
- “Does the rule depend on who is asking, such as more than 10 guests only for the committee?” Yes, it depends on the user: rules out A model field validator, Model.clean() and A database constraint. Models and the database never see the request, so they cannot tell a committee member from any other resident.
In short, a form’s validators and its clean() give the best message, at the field, and clean() can see what the
view passes in, such as the user, but they guard only the writes that go through that form. A model validator or
Model.clean() guards every ModelForm and the admin. A database constraint guards every write, including update(),
bulk_create() and two requests in the same second, but only reports the whole row.
Rules that must always hold, such as “ends after it starts”, go in the database and are mirrored in the form for the
message; rules about who is asking live where the request is, the form or the view.
Key idea
Write a rule once where every write meets it, usually the model and the database, and mirror it in the form only for a message at the field.
Checkpoint
Check: Where a rule belongs
A good error message says how to fix the value
A resident who meets an error has one question: what do I type instead? Compare the messages of the first run. “Enter a
valid value.”, RegexValidator’s default, answers nothing; "G-1203" is not a flat code. Write the block, a hyphen and the number, such as B-1203. names the value, the rule and an example. Three habits make every message like the
second:
| Habit | Weak | Strong |
|---|---|---|
| Say what to type | Enter a valid value. | Enter the 11 characters of the IFSC, such as ABCD0123456. |
| Name the rule | Invalid booking. | Book at most 4 hours at a time. |
| Keep internals out | CHECK constraint failed: booking_ends_after_start | Choose an end time after the start time. |
The last row is about safety, not style. OWASP’s Error Handling Cheat Sheet warns that details such as a table, a
constraint or a stack trace tell an attacker how the site is built, so a view that catches IntegrityError shows the
form’s own message and logs the rest. OWASP’s Input Validation Cheat Sheet adds the matching rule for the checks
themselves: describe what is allowed, an allowlist such as “letters A to F”, rather than trying to list what is not,
and keep rules narrow only where the data is narrow. A flat code has one shape; a resident’s name does not, so a name
field checks its length and leaves the letters alone.
One code for each mistake
When a value can be wrong in several ways, give each way its own code and message, as the exercise below does for an IFSC. Tests then assert the exact mistake, and the resident reads exactly what to change.
Checkpoint
Check: Error messages that help
Practise: an IFSC validator that explains itself
The refund form of Nivas checks the IFSC with one pattern and one message. Write validate_ifsc(value), which raises
one ValidationError with its own code and a message with an example for each of four mistakes, and
clean_ifsc(value), the conversion step that removes spaces and writes capitals before the validator runs. The starter
file holds a small ValidationError like Django’s, so the eight sample tests run in your browser:
Exercise · Easy · Python
A validator whose errors say how to fix the value
The refund form of Nivas asks for the IFSC of a resident's bank branch, and its one-line validator answers every mistake with the same message. Write two functions that do better. ValidationError in the starter file works like Django's: a message, a code and params.
validate_ifsc(value) returns nothing for a valid IFSC: 11 characters, four capital letters for the bank, the digit 0, then six capital letters or digits for the branch, such as ABCD0123456. For anything else it raises one ValidationError, for the first problem it finds, checked in this order:
- code
'ifsc_length'when the value is not 11 characters long, withparams={'length': <its length>}; - code
'ifsc_bank'when the first four characters are not all capital letters A to Z; - code
'ifsc_zero'when the fifth character is not0; - code
'ifsc_branch'when the last six are not all capital letters or digits.
Every message must tell the resident how to fix the value, so every message contains the example ABCD0123456.
clean_ifsc(value) is the field's conversion, the step that runs before any validator: it removes every space, writes the letters in capitals, calls validate_ifsc on the result and returns it. So clean_ifsc(' abcd 0123456 ') returns 'ABCD0123456', while validate_ifsc('abcd0123456') raises ifsc_bank.
Starter code · ifsc.py
class ValidationError(Exception):
"""A stand-in for django.core.exceptions.ValidationError: a message, a code and params."""
def __init__(self, message, code=None, params=None):
super().__init__(message % params if params else message)
self.message = message
self.code = code
self.params = params
def validate_ifsc(value):
"""Raise ValidationError, with a code and a message that says how to fix it, unless value is a valid IFSC."""
if len(value) != 11:
raise ValidationError('Enter a valid IFSC.', code='invalid')
def clean_ifsc(value):
"""Remove the spaces, write the letters in capitals, validate and return the IFSC."""
return value The sample tests · test_ifsc.py
from ifsc import ValidationError, clean_ifsc, validate_ifsc
def error_of(check, value):
"""The ValidationError that check(value) raises (the test fails if it raises none)."""
try:
check(value)
except ValidationError as error:
return error
raise AssertionError(f'{check.__name__}({value!r}) raised no ValidationError')
def test_valid_codes_pass():
for value in ['ABCD0123456', 'ABCD0ABCDEF', 'WXYZ0A1B2C3']:
assert validate_ifsc(value) is None, value
def test_the_length_comes_first_with_its_params():
for value in ['ABCD012345', 'ABCD01234567', '', 'abcd']:
error = error_of(validate_ifsc, value)
assert error.code == 'ifsc_length', value
assert error.params == {'length': len(value)}, value
def test_the_bank_part_is_four_capital_letters():
for value in ['abcd0123456', 'AB1D0123456', 'ABC 0123456', 'ÄBCD0123456']:
assert error_of(validate_ifsc, value).code == 'ifsc_bank', value
def test_the_fifth_character_is_a_zero():
for value in ['ABCD1123456', 'ABCDO123456', 'ABCD 123456']:
assert error_of(validate_ifsc, value).code == 'ifsc_zero', value
def test_the_branch_is_six_capital_letters_or_digits():
for value in ['ABCD0abc123', 'ABCD012345-', 'ABCD01234 6']:
assert error_of(validate_ifsc, value).code == 'ifsc_branch', value
def test_one_error_for_the_first_problem():
assert error_of(validate_ifsc, 'abcd1123456').code == 'ifsc_bank'
assert error_of(validate_ifsc, 'ABCD1abc123').code == 'ifsc_zero'
def test_every_message_shows_a_valid_example():
for value in ['ABCD012345', 'abcd0123456', 'ABCD1123456', 'ABCD0abc123']:
assert 'ABCD0123456' in str(error_of(validate_ifsc, value)), value
def test_clean_ifsc_removes_spaces_and_writes_capitals():
assert clean_ifsc(' abcd 0123456 ') == 'ABCD0123456'
assert clean_ifsc('wxyz0a1b2c3') == 'WXYZ0A1B2C3'
assert error_of(clean_ifsc, 'abcd 1123456').code == 'ifsc_zero' A hint
Test the four rules in the order of the prompt and raise as soon as one fails, so each value gets one error. The string methods do most of the work: value[:4] for the bank part, value[4] for the fifth character and value[5:] for the branch. 'ABcD'.isalpha() is not enough on its own, because it also accepts lower-case and non-English letters: compare each character with 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check the five ideas of this lesson, from validators to messages, one question at a time, the weakest idea first:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): When do you write a validator, and when a clean_<field>() method?
A validator is a function or class that receives one value and nothing else, so it suits rules about the value
itself: its shape, its range, its characters. Because it knows no form, the same validator can guard a model field, a
form field and a serializer, and it runs wherever the field is validated. A clean_<field>() method belongs to one
form: it runs after the field’s own checks and validators passed, it can read self.cleaned_data and anything the
view gave the form, such as the user, and its return value replaces the field’s cleaned value. Use it for rules that
need data or context, such as looking up a flat by its code, and for turning the value into something else, such as
the Flat row.
Two design questions of this module’s bank start from these bookings: where each booking rule belongs, and an import that filled the table with bookings the model refuses.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Clubhouse bookings must end after they start, last at most 4 hours, never overlap another booking, and allow more than 10 guests only for committee members. Bookings arrive from the web form, the admin and a mobile API. Where do you enforce each rule, and why there?
-
Mid to senior
A script imported 2,000 clubhouse bookings from a spreadsheet with bulk_create(). A week later the calendar shows 9-hour bookings and bookings with 40 guests, although the model has MaxValueValidator(10) on guests and a clean() that refuses more than 4 hours. Why did the rules not stop these rows, and what do you change?
Key takeaways
- A validator receives one value and raises
ValidationErrorwith a message, a code and params; it can guard model fields and form fields alike, and aRegexValidatorpattern needs\Aand\Zbecause it is searched for. - Each field runs
to_python(),validate(), its validators and then the form’sclean_<field>(); a failed field skips itsclean_<field>(), and the form’sclean()runs last, always. full_clean()runs the field validators,Model.clean(), the uniqueness checks and the constraints;save(),update()andbulk_create()run none of them, and only the database guards those paths.- A rule belongs where every write meets it, usually the model and the database, with a copy in the form only for a message at the field; rules about who is asking live in the form or the view.
- A good message says what to type, names the rule and reveals no table, constraint or stack trace.
Terms in this lesson
- full_clean()
- The model method that validates an object in four steps: its field validators, Model.clean(), the uniqueness checks and Meta.constraints. ModelForms call it; save(), update() and bulk_create() never do.
- ValidationError
- The exception Django's validation raises and collects. It carries a message for people, a code for programs that does not change with translation, and params that fill the placeholders of the message.
- validator
- A callable that receives one value and raises ValidationError when the value breaks a rule, such as validate_flat_code. Because it sees only the value, the same validator can guard model fields and form fields.
References
- Validators (Django Software Foundation)
- Form and field validation (Django Software Foundation)
- Model instance reference: validating objects (Django Software Foundation)
- Constraints reference (Django Software Foundation)
- QuerySet API reference: update() and bulk_create() (Django Software Foundation)
- Creating forms from models (Django Software Foundation)
- Python re module (re.search and re.fullmatch) (Python Software Foundation)
- Frequently Asked Questions: NEFT (IFSC) (Reserve Bank of India)
- Input Validation Cheat Sheet (OWASP Foundation)
- Error Handling Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress