Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

The validators of Nivas, on a model and a form field

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:

Two validators, an unanchored pattern and one validator in two places Python · nivas/check_validators.py
"""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

  1. Question 1 of 2 RegexValidator(r'[A-Z]{4}0[A-Z0-9]{6}') checks an IFSC field. A resident types 'refund to ABCD0123456 please'. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It passes, because RegexValidator searches for the pattern anywhere in the value

    A pattern for a whole value needs anchors: \A at the start and \Z at the end, or a function validator that uses re.fullmatch(), as validate_flat_code does.

  2. Question 2 of 2 validate_flat_code('G-1203') raises a ValidationError. Which part of it should a test compare?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its code, flat_code, which stays the same whatever language the message is shown in

    A ValidationError carries a message for people, a code for programs and params for the message's placeholders, such as {'value': 'G-1203'}.

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():

BookingForm Python · nivas/amenities/forms.py
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
The steps of five bookings, recorded Python · nivas/cleaning_order.py
"""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:

Interactive · stage by stage The order in which Django cleans a form Step through the bookings of the recorded run, and watch which steps each one reaches.
The order in which Django cleans a form, step 10 of 10. Showing field.to_python() ('b-1203' becomes 'B-1203'), field.validate(), field.run_validators() (D-404 passes), form.clean_flat() (D-404: unknown_flat), form.clean() (ends_at: ends_before_start), cleaned_data and errors (errors: ends_at). In focus: form.clean().the next fielda failed field skips aheadfield.to_python()'b-1203' becomes 'B-1203'field.validate()the required checkfield.run_validators()D-404 passesform.clean_flat()D-404: unknown_flatform.clean()ends_at: ends_before_startcleaned_data and errorserrors: ends_at

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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().
  8. 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.
  9. 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.
  10. 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

  1. Question 1 of 2 Booking 2 sends flat=G-1203, which validate_flat_code refuses. Which of the form's methods run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: clean() runs; clean_flat() does not

    For each field, a failure in to_python(), validate() or the validators stops that field's chain, including its clean_<field>(). The form's clean() runs after the last field in every case.

  2. Question 2 of 2 A form capitalises the flat in clean_flat() and also has validate_flat_code on the field. A resident types b-1203. What is the result, and the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: flat_code refuses b-1203 before clean_flat() runs; capitalise in the field's to_python() instead

    A step can only help the steps after it: convert in to_python(), check in validators, look up in clean_<field>(), compare fields in clean().

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:

Two bad bookings, five ways of writing them Python · nivas/where_rules_live.py
"""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

  1. One question A view builds a 6-hour Booking and calls save(). Booking.clean() refuses bookings over 4 hours. What happens?

    Choose one answer.

    Show the answer: One question

    Answer: The booking is saved; save() runs no validation in Python, and no database rule covers the 4 hours

    full_clean() runs the field validators, Model.clean(), the unique checks and the constraints. save(), update() and bulk_create() run none of them, so a rule that must always hold also belongs in the database.

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”:

Interactive · answer and compare Where should this rule live? Answer three questions about one rule. The table marks the places that can hold it and says why the others cannot.
Can rows be written without this form, by the admin, a script, an import or an API?
Does the rule compare the row with other rows, such as one booking per flat each evening?
Does the rule depend on who is asking, such as 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.

Five places a rule can live, against what each one sees and which writes run it
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

  1. Question 1 of 2 Bookings come from the web form, the admin and an import script. The rule is "one booking per flat each evening". Where does it belong?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: In a database constraint, with the same check in the form for a message at the field

    Rules that compare rows and must always hold go into the database, as a unique or exclusion constraint; the form repeats the check for a friendly message, and the view catches IntegrityError for the race.

  2. Question 2 of 2 "More than 10 guests only for committee members" depends on who books. Where can Django check it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the form's clean() or the view, which can see the user

    Rules about who is asking live where the request is: the view, or a form that the view gives the user. Every other path that writes, such as an API, needs its own permission check.

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.

Regex Tester Try the patterns of this lesson on your own values; its patterns are JavaScript's, so write ^ and $ where Python uses \A and \Z.

Checkpoint

Check: Error messages that help

  1. One question A view catches IntegrityError and shows its text: "CHECK constraint failed: booking_ends_after_start". What is wrong?

    Choose one answer.

    Show the answer: One question

    Answer: It tells the resident nothing they can do, and it tells anyone the names of a table's constraints

    A message says what to type and names the rule; internals go to the log. "Choose an end time after the start time." does both jobs.

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:

  1. code 'ifsc_length' when the value is not 11 characters long, with params={'length': <its length>};
  2. code 'ifsc_bank' when the first four characters are not all capital letters A to Z;
  3. code 'ifsc_zero' when the fifth character is not 0;
  4. 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'.

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.

  1. Question 1 of 12 What does a Django validator receive, and what does it do with a bad value?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: It receives the value only, and raises ValidationError

    A validator is a callable that takes one value and raises ValidationError when the value breaks a rule. Because values are converted to Python before validators run, the same validator works on forms and models.

  2. Question 2 of 12 Why did validate_flat_code give the same message on Flat.code and on a form field in the recorded run?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Both fields call the same function with the value, and the function knows nothing else

    Write a rule about a value once, as a validator, and attach it to every field that holds such a value: the model field for every ModelForm and full_clean(), and plain form fields where there is no model.

  3. Question 3 of 12 Put the steps Django runs for the flat field of BookingForm, then the form, in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 3 of 12

    Answer:

    1. field.to_python()
    2. field.validate()
    3. field.run_validators()
    4. form.clean_flat()
    5. form.clean()

    Each field converts, runs its built-in checks and its validators, then the form's clean_<field>(); after the last field, the form's clean() compares fields.

  4. Question 4 of 12 A form's clean() reads self.cleaned_data['ends_at'] directly, and a resident leaves ends_at empty. What happens?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: A KeyError, because a field that failed has no entry in cleaned_data, and clean() runs anyway

    In clean(), read every field with cleaned_data.get() and check only when the values you compare are there.

  5. Question 5 of 12 A colleague's clean_flat() checks the code but ends without a return statement. What does cleaned_data['flat'] hold after a valid submission?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: None, because the return value of clean_<field>() replaces the field's cleaned value

    clean_<field>() hands its result to cleaned_data. Return the value, or a new value such as the Flat row that BookingForm.clean_flat() returns.

  6. Question 6 of 12 Which of these runs a model's field validators and Model.clean()?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: full_clean()

    Model validation runs only when something calls full_clean(): a ModelForm, the admin, or your own code before it saves.

  7. Question 7 of 12 The admin edits a booking and sets it to 6 hours. Booking.clean() refuses bookings over 4 hours. What happens?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The admin shows the error at the end time, because its ModelForm calls full_clean()

    Rules in Model.clean() and in model field validators hold for every ModelForm, the admin's included, and for any code that calls full_clean(); everything else skips them.

  8. Question 8 of 12 An import script must load 2,000 bookings from a spreadsheet with bulk_create() and keep every model rule. What should it do?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Build each Booking, call full_clean() on it, collect the failures, and bulk_create() only the objects that passed

    Code that writes without a ModelForm runs the model's validation itself: full_clean() for each object, then the fast insert. Rules that must hold even if someone forgets belong in the database.

  9. Question 9 of 12 "One resident account per email address, whatever the capital letters" must hold for sign-up, the admin and an API. What is the main guard?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: A unique constraint on the lower-case email in the database

    The database guards what must always hold; the form repeats the check for a message at the field; and the view turns an IntegrityError from a race into the same message.

  10. Question 10 of 12 A search form on the notices page accepts dates "from" and "to", and the rule is that from comes before to. Nothing is saved. Where does the rule belong?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: In the search form's clean(), because the rule is about one request and nothing is written

    The placement rule follows the writes: rules about stored data go where every write meets them, and rules about one request, with nothing stored, live in the form.

  11. Question 11 of 12 Which message helps a resident most when the IFSC they typed is too short?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: An IFSC has 11 characters and this one has 10. Copy it again from your bank details, such as ABCD0123456.

    A good message says what to type, names the rule and, where it helps, gives an example of a valid value.

  12. Question 12 of 12 A colleague restricts the resident name field to the letters A to Z and spaces, to keep names clean. What does OWASP's Input Validation Cheat Sheet suggest instead?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Accept the full range of Unicode letters and punctuation real names use, and limit the length

    Narrow allowlists fit narrow data, such as a flat code or an IFSC. For open-ended text such as names, accept the characters people use and control length and encoding instead.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A validator receives one value and raises ValidationError with a message, a code and params; it can guard model fields and form fields alike, and a RegexValidator pattern needs \A and \Z because it is searched for.
  • Each field runs to_python(), validate(), its validators and then the form’s clean_<field>(); a failed field skips its clean_<field>(), and the form’s clean() runs last, always.
  • full_clean() runs the field validators, Model.clean(), the uniqueness checks and the constraints; save(), update() and bulk_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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.