skip to content

In a Django form, how do you decide whether a rule belongs in a validator, a custom Field, clean_<field>() or clean()?

level: seniorimportance: should knowfreq 36%

answer

  1. reuse versus local context
  2. one value or several
  3. conversion is the field's job
  4. validators skip empty values
  5. context arrives through __init__

basics

~10 s

In Django, put reusable single-value checks in validators, type conversion in a custom Field's to_python(), rules needing this form's context in clean_<field>(), and rules spanning fields in the form's clean().

solid answer

~40 s

I sort Django rules by reuse and scope. A check on one value that could apply anywhere, such as a booking-reference format, is a **validator**: a callable raising `ValidationError`, reusable on other form fields and model fields; `run_validators()` runs them all, aggregates their errors and skips empty values. When the raw input must become a different type, like a comma-separated list of room numbers, that is a **custom Field** overriding `to_python()` (and `validate()` for type-level rules). A rule about one field that needs this form's context, such as a promo code valid for the current user, goes in **`clean_<field>()`**, with the user passed through the form's `__init__`. Anything comparing fields goes in **`clean()`**. Database-level guarantees still need the database's own constraints.

code

python · 29 lines
python
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator

booking_ref = RegexValidator(r'^HTL-\d{6}$', 'Use the format HTL-123456.', code='bad_reference')

class RoomListField(forms.Field):
    def to_python(self, value):
        if not value:
            return []
        try:
            return [int(part) for part in value.split(',')]
        except ValueError:
            raise ValidationError('Enter room numbers separated by commas.', code='bad_rooms')

class ChangeBookingForm(forms.Form):
    reference = forms.CharField(validators=[booking_ref])
    rooms = RoomListField()
    promo_code = forms.CharField(required=False)

    def __init__(self, *args, guest, **kwargs):
        super().__init__(*args, **kwargs)
        self.guest = guest

    def clean_promo_code(self):
        code = self.cleaned_data['promo_code']
        if code and code not in self.guest.promo_codes:
            raise ValidationError('This code is not available to you.', code='promo_not_owned')
        return code

go deeper

for a junior

Recall the four places a rule can live and give one example for each.

for a middle

Explain that validators run after to_python, skip empty values and aggregate errors, and when a custom Field is warranted.

for a senior

Show you choose layers for reuse and testability, pass context through init, and back time-sensitive rules with database guarantees.

for a principal

Set team conventions for shared validators and fields so business rules are defined once and enforced consistently across forms and models.

## Four places, four jobs Django gives a form four customisation points, and each has a clear job: | Layer | Sees | Reusable | Typical rule | |---|---|---|---| | validator in `validators=[...]` | one converted value | across forms and model fields | format of a booking reference | | custom `Field` (`to_python()`, `validate()`) | the raw value, then the converted one | across forms | parse `'101, 102'` into `[101, 102]` | | `clean_<fieldname>()` | this field's clean value plus `self` | this form only | promo code valid for the signed-in guest | | `Form.clean()` | every field that passed | this form only | check-out after check-in | Choosing the wrong layer produces the classic bugs: duplicated regexes across forms, rules that silently skip empty values, and cross-field checks that depend on field order. ## Validators: small, reusable, aggregated A validator is any callable taking one value and raising `ValidationError`. Django ships many (`RegexValidator`, `MinValueValidator`, `validate_email`), and your own is a plain function. How the field runs them shapes what they can do: - `run_validators()` runs **after** `to_python()` and `validate()`, so validators receive a Python value. - It **skips empty values**. A validator never sees `''` or `None`; emptiness is `required`'s job. - It runs **every** validator and combines their errors, so a guest can be told about two problems at once. - The same function can be attached to a model field, which is the main reason to prefer a validator over a form method for format rules. ## Custom Field: when the type changes If the value needs parsing into something new, override `to_python()` in a `Field` subclass and raise `ValidationError` with a code when parsing fails. Override `validate()` for rules intrinsic to that type, calling `super().validate()` to keep the `required` check. The result is reusable wherever such values appear, and `cleaned_data` is already the right type for every later hook. ## clean_<fieldname>(): one field, this form's context Per-field hooks run after the field's own cleaning and can use anything on the form instance: 1. Accept extra context in `__init__`, for example `def __init__(self, *args, guest, **kwargs)`, and store it on `self`. 2. In `clean_promo_code()`, look the code up for `self.guest` and raise a coded `ValidationError` if it does not apply. 3. Return the (possibly normalised) value. Do not reach for other fields here; they are only present if declared earlier and valid. ## Form.clean(): relationships between fields Everything that compares values, such as dates, guest counts against room type, or "either phone or e-mail", goes in `clean()`, reading with `.get()` and reporting with `add_error()`. ## Where form validation stops - A form checks one submission at one moment. "This room is still free" can change between validation and saving, so availability needs the database's own guarantee when the booking is written; the form's check is for a friendly message. - For a `ModelForm`, model-level rules run after `clean()` in the model-validation step, which is a separate topic. ## A review checklist - Is the same regex or range check copied into several forms? Extract a validator. - Does a `clean_<fieldname>()` read another field? Move the rule to `clean()`. - Does a validator need the user or the request? It belongs in a form hook with context passed through `__init__`. - Does a hook parse strings into new types? A custom `Field` makes that reusable and keeps `cleaned_data` typed. - Does every `ValidationError` carry a `code`, so tests can target it? ## What interviewers listen for A reasoned mapping from rule to layer, awareness that validators skip empty values and aggregate errors, context passed through `__init__` rather than globals, and honesty about what a form cannot guarantee.

  • In Django forms, why does a custom validator never see an empty optional field?
    `Field.run_validators()` returns immediately when the value is one of the field's empty values. Emptiness is handled by `validate()` through `required`, so a required empty field fails there and an optional one passes without any validator running.
  • In Django, how should a form get the current user for a validation rule?
    Pass it explicitly: accept a keyword argument such as `guest` in the form's `__init__`, pop it before calling `super().__init__()` or declare it keyword-only, and store it on `self`. The view supplies it. Reading it from globals or thread-locals hides the dependency and makes the form hard to test.

saying these in an interview costs you the question

  • Validators run on the raw string before type conversion.
  • A field's validators stop at the first one that fails.
  • A validator is the right place to check that two fields agree.
  • Form validation alone guarantees a room is still free at save time.
  • Validators are called even when an optional field is empty.