Form validation in depth — clean_<field>, clean(), and ModelForm.save(commit=False)
Field types (CharField, EmailField) only validate shape — is this a valid email, is this under 100 characters. Real custom rules — is this username already taken, do these two password fields match — need clean_<field> and clean(), the two hooks the forms lesson doesn't cover.
3 min read
Field-level validation vs. custom validation: two different jobs
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
email = forms.EmailField()CharField(max_length=30) and EmailField() validate shape automatically — is this under 30 characters, does this look like an email address — the mechanics covered in the forms basics lesson. Neither one can express "is this username already taken" or "do these two fields agree with each other," because those rules depend on more than a single field's shape — they need custom code, which is what clean_<field> and clean() exist for.
clean_<field>: validating (and normalizing) one field, after its type check passes
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
def clean_username(self):
username = self.cleaned_data["username"]
if User.objects.filter(username__iexact=username).exists():
raise forms.ValidationError("This username is already taken.")
return username.lower() # returning a value REPLACES cleaned_data['username']A method named exactly clean_<fieldname> runs automatically after that field's built-in validation already passed, receives the already-type-checked value via self.cleaned_data["username"], and either raises forms.ValidationError (attached to that specific field in the rendered form) or returns a value that replaces cleaned_data["username"] — normalization (lowercasing, stripping whitespace) belongs here for exactly that reason. This is the hook for "is this value valid" checks that need a database query or other logic beyond what a field type alone can express.
clean(): validation that needs to see multiple fields at once
class SignupForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(widget=forms.PasswordInput)
def clean(self):
cleaned_data = super().clean() # ALWAYS call super() first
password = cleaned_data.get("password")
confirm = cleaned_data.get("password_confirm")
if password and confirm and password != confirm:
self.add_error("password_confirm", "Passwords do not match.")
return cleaned_dataclean() runs last, after every field's own validation and every clean_<field> has already run, and it's the only hook that sees cleaned_data for every field at once — the right place for cross-field rules like "these two fields must match" or "end_date must be after start_date," which no single field's clean_<field> could check alone since each only sees its own value. self.add_error(field, message) attaches an error to a specific field's display; calling raise forms.ValidationError(...) directly instead attaches it as a form-wide, non-field-specific error.
ModelForm.save(commit=False): validate and build the instance, without hitting the database yet
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "body"]
def create_article(request):
form = ArticleForm(request.POST)
if form.is_valid():
article = form.save(commit=False) # builds the model instance, does NOT save yet
article.author = request.user # set a field the form itself doesn't collect
article.save() # NOW it hits the databaseform.save() on a ModelForm normally validates, builds the model instance, and saves it to the database in one call. commit=False splits that into two steps — build the (unsaved) instance, but stop before the database write — which is exactly what's needed when a field has to be set from outside the form itself, such as article.author = request.user, since the currently logged-in user is never something a form field should collect from user input directly.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What can clean_<field> check that a field type like CharField or EmailField alone cannot?
2. When should validation logic go in clean() instead of clean_<field>?
3. In `def clean(self): cleaned_data = super().clean(); ...`, why call super().clean() first?
4. What does form.save(commit=False) do on a ModelForm?