Django form validation order: clean vs clean_ explained

Unexpected validation order in Django forms often appears in production user‑registration endpoints where cross‑field checks rely on a field’s original value. Django executes each clean_ method before the form-wide clean, which can silently break logic. This leads to false validation errors that are hard to trace.

# Example showing the issue
from django import forms

class SignupForm(forms.Form):
    password = forms.CharField()
    confirm = forms.CharField()

    def clean_password(self):
        # unintentionally mutates the field value
        data = self.cleaned_data.get('password')
        return data + 'X'

    def clean(self):
        cleaned = super().clean()
        if cleaned.get('password') != cleaned.get('confirm'):
            raise forms.ValidationError('Passwords differ')
        return cleaned

# Simulate a POST payload
payload = {'password': 'secret', 'confirm': 'secret'}
form = SignupForm(payload)
print('is_valid:', form.is_valid())
print('cleaned_data:', form.cleaned_data)
# Expected output:
# is_valid: False
# cleaned_data: {'password': 'secretX', 'confirm': 'secret'}
# ValidationError is raised because clean runs after clean_password.

Django’s FormBase iterates over each field, calling its clean_() method, and only afterwards invokes the form-wide clean(). Therefore any mutation performed in clean_ is seen by clean, which can cause cross‑field checks to compare altered data. This ordering is documented in the Django forms API reference and mirrors the framework’s design for field‑level isolation before aggregate validation. Related factors:

  • clean_ is intended for single‑field sanitisation only
  • clean() should contain only cross‑field logic
  • Modifying cleaned_data in clean_ affects subsequent clean() checks

To diagnose this in your code:

# In CI or local debugging you will see the mismatch immediately
print(form.errors)  # => {'__all__': ['Passwords differ']}
# The symptom is that a field that appears correct in the UI fails the form validation.
# No stack trace, just a ValidationError on the form level.

Fixing the Issue

Before (problematic ordering)

class SignupForm(forms.Form):
    password = forms.CharField()
    confirm = forms.CharField()

    def clean_password(self):
        return self.cleaned_data['password'] + 'X'  # mutates value

    def clean(self):
        cleaned = super().clean()
        if cleaned.get('password') != cleaned.get('confirm'):
            raise forms.ValidationError('Passwords differ')
        return cleaned

The gotcha: mutating a field in clean_<field> changes the value seen by clean(), causing false mismatches.

class SignupForm(forms.Form):
    password = forms.CharField()
    confirm = forms.CharField()

    def clean_password(self):
        # keep the original value; only validate format here
        pwd = self.cleaned_data['password']
        if len(pwd) < 8:
            raise forms.ValidationError('Too short')
        return pwd

    def clean(self):
        cleaned = super().clean()
        pwd = cleaned.get('password')
        conf = cleaned.get('confirm')
        if pwd and conf and pwd != conf:
            raise forms.ValidationError('Passwords differ')
        # If you really need to transform the password, do it here
        cleaned['password'] = pwd  # optional post‑processing
        return cleaned

Production‑ready variant with explicit validation

import logging
logger = logging.getLogger(__name__)

class SignupForm(forms.Form):
    password = forms.CharField()
    confirm = forms.CharField()

    def clean_password(self):
        pwd = self.cleaned_data['password']
        if not pwd.isalnum():
            logger.warning('Non‑alphanumeric password submitted')
            raise forms.ValidationError('Invalid characters')
        return pwd

    def clean(self):
        cleaned = super().clean()
        pwd = cleaned.get('password')
        conf = cleaned.get('confirm')
        if pwd and conf and pwd != conf:
            logger.info('Password mismatch for user input')
            raise forms.ValidationError('Passwords differ')
        return cleaned

By keeping field‑level cleaning pure and moving any cross‑field comparison or transformation to clean(), you guarantee the order Django expects and avoid surprising validation failures.

What Doesn’t Work

❌ Calling self.clean() manually inside clean_password(): This re‑executes the whole form clean logic prematurely and can double‑raise errors.

❌ Using self.data['password'] = modified_value inside clean_password(): Modifies the raw input dict, breaking the separation between raw and cleaned data.

❌ Returning a tuple from clean_password() to pass extra info to clean(): clean_<field> must return a single value; tuples are treated as the field value and cause type errors.

  • Mutating cleaned_data inside clean_<field> and expecting clean() to see the original value.
  • Placing cross‑field checks inside a field’s clean_<field> method, which runs before other fields are validated.
  • Calling super().clean() after manually raising a ValidationError, which prevents other field errors from being collected.

When NOT to optimize

  • Simple one‑off scripts: If the form is used in an isolated management command, the overhead of refactoring may not be worth it.
  • Read‑only forms: When a form only displays data and never validates user input, the ordering nuance is irrelevant.
  • Legacy code locked behind a stable API: If external integrations rely on the current behaviour and cannot be updated, document the quirk instead of changing it.
  • Very small projects: For tiny internal tools with a single developer, the risk of the bug surfacing is minimal.

Frequently Asked Questions

Q: Can I force clean() to run before clean_<field>?

No, Django’s form lifecycle is fixed; reorder your validation logic instead.


Understanding that Django always runs clean_<field> first clarifies why cross‑field checks behaved unexpectedly in our signup flow. By confining per‑field sanitisation to clean_<field> and reserving inter‑field logic for clean(), you align with the framework’s design and avoid silent validation bugs. This pattern scales cleanly as forms grow more complex.

Why Django middleware order changes request handlingWhy Django select_for_update can cause deadlocks