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_
# 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_
- 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.
After (recommended pattern)
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_datainsideclean_<field>and expectingclean()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 aValidationError, 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.
Related Issues
→ Why Django middleware order changes request handling → Why Django select_for_update can cause deadlocks