Fixing Django Form Validation Errors That Bypass Custom Clean Methods
You added a clean() method to your Django form, ran the view, submitted invalid data β and the form accepted it anyway. No error message, no redirect, just a silent pass. It's one of the more disorienting bugs in Django because the code looks correct.
The problem usually isn't your logic. It's the execution order, the way you're raising errors, or how the view consumes the form. This article walks through each failure mode with working code.
What you'll learn
- How Django's validation pipeline executes and where custom clean methods fit in
- Why clean errors sometimes disappear before reaching the template
- The difference between
clean()andclean_<fieldname>()and when each is skipped - How to attach errors to the right field β or to the whole form
- Common view-side mistakes that discard validation results entirely
Prerequisites
This article assumes you're working with Django 3.2 or later, using class-based or function-based views, and that you're already familiar with the basics of ModelForm or Form. The examples use Python 3.10+ syntax.
How Django's Validation Pipeline Actually Works
Django runs form validation in a strict sequence when you call form.is_valid(). Understanding that sequence is the fastest way to diagnose any bypass.
- Field-level
to_python()β converts raw input to the correct Python type. - Field-level
validate()β runs built-in validators (e.g.,MaxLengthValidator). - Field-level
run_validators()β runs any custom validators attached to the field. - Form-level
clean_<fieldname>()β your per-field custom method. - Form-level
clean()β your cross-field validation method.
The critical detail: if step 1, 2, or 3 raises a ValidationError for a field, Django marks that field as invalid and skips steps 4 and 5 for that field. Your custom clean method for a field will not run if the field already failed an earlier check.
The Most Common Bypass: Field Already Failed
If you have a clean_email() method and the EmailField itself raises a validation error because the input isn't a valid email format, your custom method is never called. This surprises developers who expect their custom logic to always run.
class ContactForm(forms.Form):
email = forms.EmailField()
def clean_email(self):
email = self.cleaned_data.get('email')
if not email.endswith('@company.com'):
raise forms.ValidationError('Only company emails are allowed.')
return email
If the user submits not-an-email, the EmailField rejects it before clean_email() runs. That's expected behavior β but if you need your custom message shown even for malformed input, you'd need a more permissive field type (like CharField) and handle all validation yourself.
Why clean() Gets Skipped for Some Fields
The cross-field clean() method always runs, but cleaned_data only contains fields that passed all earlier validation. If you try to access a field that failed, you'll get a KeyError β or worse, a silent skip if you use .get() without checking for None.
class PasswordChangeForm(forms.Form):
password1 = forms.CharField(widget=forms.PasswordInput)
password2 = forms.CharField(widget=forms.PasswordInput)
def clean(self):
cleaned_data = super().clean()
p1 = cleaned_data.get('password1') # Could be None if field failed
p2 = cleaned_data.get('password2')
if p1 and p2 and p1 != p2:
raise forms.ValidationError('Passwords do not match.')
return cleaned_data
Notice the guard if p1 and p2. Without it, if either password field failed an earlier check, you'd be comparing None != p2, which is always true β and you'd raise a confusing mismatch error even when the real problem was something else entirely.
Attaching Errors to the Right Place
Errors raised in clean() without a field reference go into form.non_field_errors(). Errors raised in clean_<fieldname>() go into that field's error list. If you render errors manually in your template and only show field.errors, non-field errors will never be displayed.
Make sure your template includes both:
<!-- Show non-field errors -->
{% if form.non_field_errors %}
<ul class="errorlist nonfield">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<!-- Per-field errors shown inline -->
{% for field in form %}
{{ field.label_tag }}
{{ field }}
{% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
If you want a cross-field error to appear next to a specific field rather than at the top of the form, use self.add_error() inside clean():
def clean(self):
cleaned_data = super().clean()
start = cleaned_data.get('start_date')
end = cleaned_data.get('end_date')
if start and end and start >= end:
self.add_error('end_date', 'End date must be after start date.')
return cleaned_data
Using add_error('end_date', ...) places the message under end_date in the template and also removes end_date from cleaned_data, which prevents downstream code from accidentally using the bad value.
View-Side Mistakes That Discard Validation
Sometimes the form validation runs correctly, but the view throws away the result. These are quiet bugs that are hard to trace unless you know what to look for.
Calling is_valid() but ignoring the return value
# Broken: form.is_valid() is called but its result isn't checked
def my_view(request):
form = ContactForm(request.POST)
form.is_valid() # result discarded!
do_something(form.cleaned_data) # runs even when form is invalid
return redirect('success')
# Fixed
def my_view(request):
form = ContactForm(request.POST)
if form.is_valid():
do_something(form.cleaned_data)
return redirect('success')
return render(request, 'contact.html', {'form': form})
Instantiating the form without request.POST
# Broken: unbound form β is_valid() will always return False
# but no errors are shown because the form was never bound
def my_view(request):
form = ContactForm() # missing request.POST
if form.is_valid():
...
A bound form is one created with data: ContactForm(request.POST). An unbound form is one created without data: ContactForm(). Only bound forms run validation. Always check that you're passing request.POST on POST requests.
Re-instantiating the form before rendering
# Broken: errors are lost because a fresh form is created
def my_view(request):
if request.method == 'POST':
form = ContactForm(request.POST)
if form.is_valid():
return redirect('success')
form = ContactForm() # overwrites the invalid form with its errors!
return render(request, 'contact.html', {'form': form})
# Fixed: use else to avoid overwriting
def my_view(request):
if request.method == 'POST':
form = ContactForm(request.POST)
if form.is_valid():
return redirect('success')
else:
form = ContactForm()
return render(request, 'contact.html', {'form': form})
ModelForm Validation and the Meta Validators
With ModelForm, validation also runs model-level validators defined on the model fields. If a validators=[] list on the model field rejects the data, your form-level clean_fieldname() won't run for that field.
Additionally, ModelForm calls Model.full_clean() under the hood, which includes clean_fields(), clean(), and validate_unique(). If you override clean() on both the model and the form, both run β but in separate phases. Keep model-level constraints in the model, and form-level or UI-specific constraints in the form.
class Article(models.Model):
slug = models.SlugField(unique=True)
published_at = models.DateTimeField(null=True, blank=True)
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['slug', 'published_at']
def clean_slug(self):
slug = self.cleaned_data.get('slug')
# instance check lets you exclude the current object on edit
qs = Article.objects.filter(slug=slug)
if self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise forms.ValidationError('This slug is already in use.')
return slug
Common Pitfalls Summary
- Not returning cleaned_data from
clean()β the method must return the dict, orcleaned_databecomesNoneafter the call. - Raising the wrong exception type β only
forms.ValidationErroris caught by the validation pipeline. Raising a plainValueErrorwill bubble up as an unhandled exception. - Calling
self.cleaned_data[field]instead of.get(field)insideclean()β causes aKeyErrorif the field already failed validation. - Forgetting
super().clean()β required to populatecleaned_databefore you access it in a cross-field clean. - Silencing exceptions in the view β a bare
except Exception: passaround form processing will hide any error that escapes the validation pipeline.
Wrapping Up
Django's form validation pipeline is predictable once you understand its execution order. Most bypass bugs come down to one of three things: the field already failed an earlier check, the error was attached to the wrong place, or the view discarded the result.
Here are four concrete actions you can take right now:
- Add a
print(form.errors)immediately afterform.is_valid()in your view to confirm whether errors are being generated at all. - Check your template for
{{ form.non_field_errors }}and confirm it's rendered β cross-field errors are often invisible because this line is missing. - Audit your
clean()method: verify it callssuper().clean(), uses.get()for field access, and returnscleaned_data. - Review your view for the three patterns above β discarded return value, unbound form, and overwritten form instance.
- For
ModelFormuniqueness checks, useself.instance.pkto exclude the current record, otherwise editing an existing object will always fail its own slug check.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!