skip to content

In a Django formset, where do you put validation that spans several forms, and how do its errors reach the template?

level: middleimportance: should knowfreq 38%

answer

  1. a hook on the formset class
  2. runs after every form cleaned
  3. errors belong to no single form
  4. call the parent on model formsets

basics

~20 s

Cross-form rules go in clean() on a BaseFormSet subclass passed to the factory with formset=. It runs after every form is cleaned; a ValidationError raised there lands in formset.non_form_errors(), which the template must render explicitly.

solid answer

~40 s

Each form cleans itself first; a rule that needs several forms — total quantity across order lines, at least one line left — goes in `clean()` on a `BaseFormSet`, `BaseModelFormSet` or `BaseInlineFormSet` subclass passed with `formset=`. Django calls it after every form's validation and after the `validate_max`/`validate_min` count checks — and skips it when a count check has already failed. Start with `if any(self.errors): return` so incomplete `cleaned_data` does not crash the check, and skip forms marked for deletion and untouched spare forms. A `ValidationError` raised there becomes a **non-form error**: `formset.non_form_errors()` returns it, `is_valid()` becomes `False`, and the template must render `{{ formset.non_form_errors }}`, which carries the CSS class `nonform`. On model and inline formsets call `super().clean()`, because the base `clean()` runs the cross-form unique checks.

code

python · 25 lines
python
from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet, inlineformset_factory

from .models import Order, OrderLine


class BaseOrderLineFormSet(BaseInlineFormSet):
    def clean(self):
        super().clean()  # keeps the cross-form unique checks
        if any(self.errors):
            return
        live = [
            form
            for form in self.forms
            if form.cleaned_data and not self._should_delete_form(form)
        ]
        if not live:
            raise ValidationError("Keep at least one line on the order.", code="no_lines")
        if sum(form.cleaned_data["quantity"] for form in live) > 100:
            raise ValidationError("An order may hold at most 100 units.", code="too_many_units")


OrderLineFormSet = inlineformset_factory(
    Order, OrderLine, formset=BaseOrderLineFormSet, fields=["sku", "quantity"]
)

go deeper

for a junior

Know that a formset has its own clean() and that its errors are read with non_form_errors(), which the template must render.

for a middle

Explain the run order — management form, each form, count checks, then clean() — and why the hook guards on any(self.errors) and skips deleted and empty forms.

for a senior

Point out that skipping super().clean() on a model formset drops cross-form unique checks, and choose between a non-form error and add_error on the offending row.

for a principal

Decide which rules the database should enforce as constraints and which only the page can check, so a second entry point cannot bypass the formset's rules.

## Where cross-form rules live A Django formset validates in two layers. Each form cleans itself first — its fields, its `clean_<field>()` methods and its `clean()` — exactly as a standalone form would. Rules that need **more than one form** cannot live there, because a form sees only its own data. For those the formset has its own hook, **`clean()`**, which you override on a subclass and pass to the factory with `formset=`: - a formset from `formset_factory` subclasses `BaseFormSet`; - one from `modelformset_factory` subclasses `BaseModelFormSet`; - one from `inlineformset_factory` subclasses `BaseInlineFormSet`. Typical cross-form rules on an order-and-lines page: the total quantity across all lines may not exceed a limit, at least one line must remain after deletions, or no product may appear on two lines. ## The order things run in `formset.is_valid()` (or reading `formset.errors`) triggers the formset's `full_clean()`, which: 1. Validates the management form; a missing one becomes a non-form error. 2. Runs every form's own validation; forms ticked for deletion are validated too, but their errors do not count against the formset. 3. Checks the form count against `validate_max` / `absolute_max` and `validate_min`. 4. Calls **`self.clean()`** — your hook — once all of that has run, provided no count check failed in step 3. A `ValidationError` raised in step 3 or 4 is stored as the formset's **non-form errors**, returned by `formset.non_form_errors()`. `is_valid()` returns `True` only when every form not marked for deletion is valid **and** that list is empty. ## Writing the hook safely ```python class BaseOrderLineFormSet(BaseInlineFormSet): def clean(self): super().clean() if any(self.errors): return total = 0 for form in self.forms: if not form.cleaned_data or self._should_delete_form(form): continue total += form.cleaned_data["quantity"] if total > 100: raise ValidationError("An order may hold at most 100 units.", code="too_many_units") ``` The details that trip people up: - **Guard with `if any(self.errors): return`.** If an individual form failed, its `cleaned_data` is incomplete, and a cross-form check would hit a missing key or report a misleading second error. - **Skip deleted forms and untouched spare forms.** An untouched extra row has an empty `cleaned_data`; a row ticked for deletion is still in `self.forms`. `self._should_delete_form(form)` is the helper Django's own formset documentation uses for that test. - **Call `super().clean()` on model and inline formsets.** `BaseModelFormSet.clean()` runs `validate_unique()`, which checks the model's unique fields, `unique_together` and unconditional field-based `UniqueConstraint`s **across** the submitted forms — something no single form can see. Overriding `clean()` without calling it silently drops that check. On an inline formset the parent foreign key counts as shared by every row, so a `UniqueConstraint(fields=["order", "sku"])` on `OrderLine` is enough to reject two lines with the same SKU. - **Attach an error to one form when one form is to blame.** From the formset's `clean()` you can call `form.add_error("quantity", "...")`; that form becomes invalid, so `is_valid()` still returns `False`, and the message renders beside the field instead of at the top. ## How errors reach the template | API | returns | holds | |---|---|---| | `formset.non_form_errors()` | an `ErrorList` | errors from `clean()`, the count checks and the management form | | `formset.errors` | a list of `ErrorDict`s | each form's own errors, in form order, skipping forms marked for deletion | | `formset.total_error_count()` | an int | the non-form errors plus every form's errors | The template must render the non-form errors explicitly — they are not attached to any field: ```django {{ formset.management_form }} {{ formset.non_form_errors }} {% for form in formset %}{{ form }}{% endfor %} ``` The list renders with the extra CSS class `nonform` (`<ul class="errorlist nonform">`), so it can be styled apart from field errors. A page that renders only the forms shows a formset that fails `is_valid()` with no visible message — a common "my formset silently won't save" report. ## Where per-row validation belongs For model and inline formsets each form is a `ModelForm`, so model field validation and the model's own `clean()` run inside step 2, once per row. The formset's `clean()` is for what spans rows; a rule about one row belongs on the form or the model.

  • Why is `if any(self.errors): return` at the top of formset.clean() recommended?
    A form that failed its own validation has incomplete `cleaned_data`, so a cross-form check could raise `KeyError` or report a second, misleading error. The formset is already invalid in that case, so returning early loses nothing and lets the per-field messages speak.
  • How do you flag one specific line from a formset-wide rule?
    Call `form.add_error("field", "message")` on that form inside the formset's `clean()`. The form becomes invalid, so `is_valid()` returns `False`, and the message renders next to the field rather than in `non_form_errors()`.

saying these in an interview costs you the question

  • a rule comparing several forms can go in each form's own clean()
  • an error raised in formset.clean() appears in the last form's errors
  • overriding clean() on a model formset needs no super() call
  • non-form errors render automatically when the template loops over the forms
  • forms ticked for deletion are removed from self.forms before clean() runs