In a Django booking form, how do you enforce that check-out follows check-in, and why use cleaned_data.get() in clean()?
answer
- rules spanning two fields
- the form-wide hook
- failed fields are missing
- attach the error to a field
- add_error removes the value
basics
~10 sPut a Django cross-field rule in the form's clean(): read both dates with cleaned_data.get(), compare only when both exist, and call add_error('check_out', ...). A field that failed validation is absent, so indexing raises KeyError.
solid answer
~40 sCross-field rules belong in `Form.clean()`, which Django runs after every field has been cleaned, including when some failed. I call `super().clean()`, then read `check_in` and `check_out` with `.get()`, because a date that failed parsing has been removed from `cleaned_data` and `['check_in']` would raise `KeyError`. Only if both are present do I compare them; if check-out is not after check-in I call `self.add_error('check_out', ValidationError(..., code='checkout_before_checkin'))`, which shows the message next to that field and removes `check_out` from `cleaned_data`. Raising `ValidationError` directly from `clean()` would instead produce a non-field error. I avoid putting the rule in `clean_check_out()`: it sees only fields declared and cleaned before it, so it breaks when fields are reordered.
code
python · 23 linesfrom django import forms
from django.core.exceptions import ValidationError
class BookingForm(forms.Form):
check_in = forms.DateField()
check_out = forms.DateField()
guests = forms.IntegerField(min_value=1, max_value=4)
def clean(self):
cleaned = super().clean()
check_in = cleaned.get('check_in')
check_out = cleaned.get('check_out')
if check_in and check_out and check_out <= check_in:
self.add_error('check_out', ValidationError(
'Check-out must be after check-in.',
code='checkout_before_checkin',
))
return cleaned
f = BookingForm(data={'check_in': '2026-10-05', 'check_out': '2026-10-03', 'guests': '2'})
f.is_valid() # False
f.has_error('check_out', 'checkout_before_checkin') # True
'check_out' in f.cleaned_data # Falsego deeper
Recall that rules involving two fields go in the form's clean() method, and that you read values with cleaned_data.get().
Explain why failed fields are missing from cleaned_data, how add_error() differs from raising, and what each does to cleaned_data.
Show you guard against partial data, attach errors where users look, give them codes for tests, and keep rules independent of field order.
Weigh which date and availability rules the form should own and which must be re-checked where the booking is actually committed.
## Why the rule cannot live on one field A hotel booking form has `check_in` and `check_out` date fields. Each is valid on its own; only together can Django decide whether the stay makes sense. Django's per-field layers (`to_python()`, `validate()`, validators and `clean_<fieldname>()`) each look at one value, so the rule belongs in **`Form.clean()`**, the hook the documentation reserves for validation that needs several fields. ## What Form.clean() can rely on - It runs **after** every field has been through `Field.clean()` and its `clean_<fieldname>()` method. - It runs **even if** some of those raised errors. - A field that failed has been **removed** from `cleaned_data`, and its message is already in `self.errors`. - Values that are present are Python objects: `datetime.date` instances here, not strings. The consequence is the `.get()` rule. If a guest types `31/02/2026` into check-in, `check_in` is not in `cleaned_data`, and `cleaned_data['check_in']` raises `KeyError`, turning a validation message into a server error. Reading with `.get()` and comparing only when both values are present keeps the form reporting the parsing error and nothing else. ## Reporting the error where the user looks There are three ways to report a cross-field failure: | Technique | Where the message appears | Effect on `cleaned_data` | |---|---|---| | `raise ValidationError('...')` | non-field errors (`'__all__'`) | unchanged | | `self.add_error('check_out', error)` | on `check_out` | `check_out` removed | | `raise ValidationError({'check_out': error})` | on `check_out` | `check_out` removed | `add_error()` is the most flexible: it does not stop `clean()`, so several independent rules can each add an error and the user sees them all. Passing `None` as the field name records a non-field error. Naming a field the form does not have raises `ValueError`, which surfaces typos early. For a date-range rule, attaching the message to `check_out` usually reads best. A rule that is not about any single field, such as "these dates overlap an existing reservation", fits a non-field error. ## A step-by-step clean() 1. Call `cleaned = super().clean()`. On a plain `Form` it returns `self.cleaned_data`; on subclasses and mixins it keeps their rules running. 2. Read each value with `.get()`. 3. Return early if either is missing, since the field error already explains the problem. 4. Compare, and call `add_error()` with a `ValidationError` carrying a `code`. 5. Return `cleaned` (or nothing; `None` keeps the current dictionary). ## Why not clean_check_out()? Django cleans fields in declaration order, and `clean_check_out()` would see `check_in` only if it is declared earlier **and** passed. The rule silently stops working when someone reorders the fields or when `check_in` fails, and it hides the dependency from readers. Per-field hooks are for rules about one field, such as normalising a promo code. ## Testing the rule Four cases cover a date-range rule completely: 1. A valid pair: `is_valid()` is True and both dates are in `cleaned_data`. 2. Reversed dates: `has_error('check_out', 'checkout_before_checkin')` is True. 3. Same-day check-in and check-out: decide whether a zero-night stay is allowed and pin the behaviour with a test. 4. An unparseable check-in: the form reports only the parsing error on `check_in`, and no `KeyError` escapes from `clean()`. The fourth case is the one that catches the missing `.get()` guard, and it is the one most often left out. ## What interviewers listen for - Cross-field rules in `clean()`, not in a per-field method. - `.get()` plus a presence guard, with the reason: failed fields are removed. - `add_error()` versus raising, and the effect on `cleaned_data`. - A `code` on the error so tests can assert it.
- In a Django form's clean(), what is the difference between add_error() and raising ValidationError?Raising stops `clean()` at that point and records the error as a non-field error unless you raise a dict keyed by field names. `add_error()` records the error and lets `clean()` continue, so several rules can each report a problem; it attaches to the named field, or to non-field errors when the name is `None`, and removes that field from `cleaned_data`.
- When could a Django form's clean_check_out() legitimately read check_in from cleaned_data?Only when `check_in` is declared earlier in the form and cleaned without error, because fields are processed in declaration order. That coupling is fragile and invisible, so the documented place for rules spanning fields is `Form.clean()`.
A hotel receptionist first checks each line of the booking card on its own, then reads the card as a whole to see whether the dates make sense. If the check-in line is illegible, the receptionist cannot compare dates and simply points at that line.
saying these in an interview costs you the question
- cleaned_data['check_in'] is always safe to index inside clean().
- Form.clean() runs only when every field is already valid.
- add_error() immediately stops the clean() method.
- Errors raised in clean() are attached to the last field cleaned.
- Cross-field rules belong in clean_<field>() of the later field.