Khi gọi form.is_valid(), Django chạy validation theo 3 tầng nối tiếp nhau.
Tầng 1 (per-field): chạy to_python() → built-in validator của field (max_length, EmailValidator...) → custom validator trong validators=[...]. Tầng 2 (clean_<fieldname>()): method tự viết để clean 1 field, có quyền raise ValidationError hoặc trả giá trị đã clean qua self.cleaned_data['<field>']. Tầng 3 (clean()): method toàn form, dùng cho ràng buộc liên field (như password và confirm phải khớp), trả về dict cleaned_data.
class SignupForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(widget=forms.PasswordInput)
def clean_password(self):
pw = self.cleaned_data['password']
if len(pw) < 8:
raise ValidationError('Tối thiểu 8 ký tự')
return pw
def clean(self):
data = super().clean()
if data.get('password') != data.get('password_confirm'):
raise ValidationError({'password_confirm': 'Mật khẩu không khớp'})
return dataLỗi field-level đi vào form.errors['<field>']; lỗi từ clean() không gắn field nào thì đi vào form.non_field_errors().
Trong clean_<field>, nếu field trước đó đã fail validation thì key đó không có trong cleaned_data — dùng .get() thay vì [key] để tránh KeyError.
When form.is_valid() is called, Django runs three tiers in order:
1. Per-field: to_python() → built-in field validators (max_length, EmailValidator...) → custom validators in validators=[...].
2. clean_<fieldname>(): a hand-written method cleaning one field, free to raise ValidationError or return the cleaned value. Accesses self.cleaned_data['<field>'].
3. clean(): a whole-form method for cross-field constraints (e.g. password and confirm password must match). Returns the cleaned_data dict.
class SignupForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(widget=forms.PasswordInput)
def clean_password(self):
pw = self.cleaned_data['password']
if len(pw) < 8:
raise ValidationError('Minimum 8 characters')
return pw
def clean(self):
data = super().clean()
if data.get('password') != data.get('password_confirm'):
raise ValidationError({'password_confirm': 'Passwords do not match'})
return dataField-level errors land in form.errors['<field>']; un-attached clean() errors land in form.non_field_errors().
Pitfall: Inside clean_<field>, if a preceding field already failed validation → it is missing from cleaned_data. Use .get() instead of [key] to avoid KeyError.