Skip to main content

The Form Validation Lifecycle

The form validation lifecycle in this codebase is a multi-stage process that transforms raw input data into cleaned Python objects while identifying and collecting errors. This process is orchestrated by the BaseForm class and relies on the individual validation logic defined in Field classes.

The Validation Entry Point

Validation is only performed on "bound" forms—forms initialized with a data or files dictionary. In BaseForm.__init__, the is_bound attribute is set based on the presence of these arguments:

# django/forms/forms.py

def __init__(self, data=None, files=None, ...):
self.is_bound = data is not None or files is not None
# ...

The validation process is lazily triggered the first time the errors property is accessed or when is_valid() is called. Both methods eventually call full_clean(), which is the central coordinator for the validation lifecycle.

The Field-Level Pipeline

Before a form can perform cross-field validation, each individual field must validate its own data. This happens within the Field.clean() method, which follows a strict three-step sequence:

  1. to_python(value): Coerces the raw input (usually a string) into a Python object (e.g., an integer, datetime, or list).
  2. validate(value): Performs basic validation that doesn't fit into the validator framework, such as checking if a required field is empty.
  3. run_validators(value): Iterates through the self.validators list and executes each one.
# django/forms/fields.py

def clean(self, value):
value = self.to_python(value)
self.validate(value)
self.run_validators(value)
return value

Chaining Validations with ComboField

For complex inputs that must satisfy multiple field types, ComboField allows chaining multiple Field instances. Its clean() method runs the value through the clean() method of every field in its self.fields list sequentially:

# django/forms/fields.py

class ComboField(Field):
def clean(self, value):
super().clean(value)
for field in self.fields:
value = field.clean(value)
return value

The Form-Level Orchestration

The BaseForm.full_clean() method manages the transition from raw data to the cleaned_data dictionary. It executes three internal cleaning stages in order:

  1. _clean_fields(): Validates individual fields.
  2. _clean_form(): Performs form-wide validation.
  3. _post_clean(): An internal hook often used by ModelForm for model-level constraints.
# django/forms/forms.py

def full_clean(self):
self._errors = ErrorDict(renderer=self.renderer)
if not self.is_bound:
return
self.cleaned_data = {}
# ...
self._clean_fields()
self._clean_form()
self._post_clean()

Stage 1: Field-Specific Cleaning

In _clean_fields(), the form iterates over every field. It first calls the field's own clean() method. If that succeeds, it looks for a method on the form named clean_<fieldname>(). This allows the form to perform additional logic on a specific field that might require access to other form state.

For example, in PasswordChangeForm, the old_password is validated against the current user:

# django/contrib/auth/forms.py

def clean_old_password(self):
old_password = self.cleaned_data["old_password"]
if not self.user.check_password(old_password):
raise ValidationError(
self.error_messages["password_incorrect"],
code="password_incorrect",
)
return old_password

Stage 2: Form-Wide Cleaning

After all individual fields are processed, _clean_form() calls the form's clean() method. This is the intended place for cross-field validation where the validity of one field depends on another.

A classic example is found in AuthenticationForm, which validates the combination of username and password:

# django/contrib/auth/forms.py

def clean(self):
username = self.cleaned_data.get("username")
password = self.cleaned_data.get("password")

if username is not None and password:
self.user_cache = authenticate(self.request, username=username, password=password)
if self.user_cache is None:
raise self.get_invalid_login_error()
return self.cleaned_data

Error Management

Errors are collected into the _errors attribute, which is an ErrorDict. The add_error() method is the standard way to associate an error with a specific field or the entire form (using None or NON_FIELD_ERRORS).

When an error is added to a field, that field is removed from cleaned_data to ensure that only valid data persists:

# django/forms/forms.py

def add_error(self, field, error):
# ... (normalization of error)
self._errors[field].extend(error_list)
if field in self.cleaned_data:
del self.cleaned_data[field]

If a field is disabled, the validation lifecycle ignores the submitted data and instead uses the initial value provided during form instantiation, as seen in Field._clean_bound_field.