Introduction
ModelForms inherit all of Django's form validation plus add model-specific checks like unique constraints. Understanding how to customize validation on ModelForms lets you enforce complex business rules while leveraging the framework's built-in protections.
Key Concepts
- clean() override: Custom form-wide validation that runs after field-level checks.
- Unique constraints: ModelForm automatically validates unique, unique_together, and UniqueConstraint.
- validate_unique(): Called during ModelForm validation to check uniqueness against the database.
Real World Context
In production applications, you often need validation that goes beyond what the model definition alone can express. For example, ensuring a blog post slug is unique within its category, or that an event's end date is after its start date. ModelForm's clean() method is where this logic lives.
Deep Dive
Overriding clean() on ModelForm
When you override clean(), always call super().clean() first. This ensures that unique constraint validation still runs:
pythonfrom django import forms from django.core.exceptions import ValidationError from .models import Event class EventForm(forms.ModelForm): class Meta: model = Event fields = ['title', 'start_date', 'end_date', 'venue'] def clean(self): # super().clean() validates unique constraints cleaned_data = super().clean() start = cleaned_data.get('start_date') end = cleaned_data.get('end_date') if start and end and end <= start: raise ValidationError( 'End date must be after the start date.' ) return cleaned_data
The call to super().clean() is critical because it sets internal flags that trigger uniqueness validation against the database.
Handling Unique Constraints
ModelForm automatically validates fields marked with unique=True, unique_together, and UniqueConstraint. You can customize error messages for these:
pythonfrom django.core.exceptions import NON_FIELD_ERRORS class ArticleForm(forms.ModelForm): class Meta: model = Article fields = ['title', 'slug', 'category'] error_messages = { NON_FIELD_ERRORS: { 'unique_together': 'An article with this slug already ' 'exists in this category.', } }
This overrides the default uniqueness error message for the unique_together constraint.
Custom Field Validation on ModelForm
Just like regular forms, you can define clean_<fieldname>() methods:
pythonclass ProfileForm(forms.ModelForm): class Meta: model = Profile fields = ['username', 'bio', 'website'] def clean_username(self): username = self.cleaned_data['username'] if username.startswith('admin'): raise ValidationError( 'Usernames cannot start with "admin".' ) return username.lower()
The returned value replaces what goes into cleaned_data, letting you normalize input.
Common Pitfalls
- Forgetting super().clean() — If you skip the super() call, unique constraint validation silently stops working. Always call it first.
- Excluding fields breaks uniqueness checks — If a field involved in a unique_together constraint is excluded from the form, Django cannot validate that constraint and skips it silently.
Best Practices
- Use error_messages in Meta — Customize constraint violation messages to be user-friendly instead of showing raw database errors.
- Keep clean() focused — Put single-field validation in clean_<fieldname>() and cross-field validation in clean(). This keeps the code organized and error messages correctly associated with fields.
Summary
- Override clean() for cross-field validation; always call super().clean() first.
- ModelForm validates unique, unique_together, and UniqueConstraint automatically.
- Use clean_<fieldname>() for single-field custom validation with value normalization.
- Customize error messages via Meta.error_messages using NON_FIELD_ERRORS.
Code Examples
class EventForm(forms.ModelForm):
class Meta:
model = Event
fields = ['title', 'start_date', 'end_date', 'venue']
def clean(self):
cleaned_data = super().clean() # validates unique constraints
start = cleaned_data.get('start_date')
end = cleaned_data.get('end_date')
if start and end and end <= start:
raise ValidationError('End date must be after start date.')
return cleaned_data