Calling obj.save() after defining validators and a clean() method on the model gives the impression that validation is guaranteed. It is not. Django does not call full_clean() during a save(), and this behavior is intentional. Understanding why changes how you architect validation in a project.
What save() actually does
The lifecycle of a save() call is shorter than you might expect:
pre_savesignal sentfield.pre_save()called on each field (auto_now,auto_now_add, etc.)INSERTorUPDATESQL based on whether apkexistspost_savesignal sent
No validation appears anywhere in this sequence. No blank check, no max_length, no call to clean(). The same applies to Model.objects.create() and Model.objects.bulk_create(): all three methods persist without validating. (bulk_create() is covered in depth in Django in_bulk() and bulk_create() if you work with bulk inserts.)
class Article(models.Model):
title = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
def clean(self):
if "spam" in self.title.lower():
raise ValidationError("Invalid title.")
# All three calls succeed without error, even with a 500-character title
Article(title="x" * 500, slug="test").save()
Article.objects.create(title="x" * 500, slug="test2")
Article.objects.bulk_create([Article(title="x" * 500, slug="test3")])
full_clean(): the four steps
full_clean() is the method to call explicitly to trigger the entire model validation chain. It runs four steps in this order:
1. clean_fields() validates each field individually: conversion to the Python type (to_python()), checking blank, null, max_length, choices, and the validators defined on the field.
2. clean() is the cross-field validation method, meant to be overridden on the model. This is where rules that depend on multiple fields at once belong.
3. validate_unique() checks uniqueness constraints: unique=True fields, unique_together, and unconditional UniqueConstraint declarations.
4. validate_constraints() checks more complex Meta.constraints (CheckConstraint, conditional UniqueConstraint, etc.).
from django.core.exceptions import ValidationError
class Event(models.Model):
name = models.CharField(max_length=100)
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError("End date must be after start date.")
event = Event(name="Conf", start_date="2026-06-10", end_date="2026-06-01")
event.full_clean() # Raises ValidationError from clean()
event.save() # Never reached
Errors raised by full_clean() are ValidationError instances. If multiple fields have errors, they are all collected before being raised together in a single dictionary.
form.is_valid(): two behaviors depending on the form type
BaseForm: its own pipeline, no model involvement
A standard form (forms.Form) has its own validation pipeline, independent of any Django model:
- For each field:
field.clean()which chains type conversion, type validation, and field validators clean_<fieldname>()if the method exists on the formclean()on the form for cross-field validation
The Django model is not involved.
ModelForm: it calls the model’s full_clean()
This is the surprising part. A ModelForm does more than a Form. After validating its own fields, it calls instance.full_clean(exclude=..., validate_unique=False) inside its _post_clean() method.
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "slug"]
form = ArticleForm(data={"title": "spam content", "slug": "test"})
if form.is_valid():
# _post_clean() has called article_instance.full_clean()
# so the model's clean() method has been executed
form.save()
In practice, if the model defines a clean() that raises a ValidationError, that error is captured and merged into the form’s errors. The validate_unique=False parameter in the call exists because the ModelForm handles uniqueness separately via its own validate_unique() method.
validators defined on model fields are also automatically copied to the corresponding ModelForm fields.
serializer.is_valid() in DRF: its own pipeline, no full_clean()
A ModelSerializer has a distinct validation pipeline, closer to a standard Form than a ModelForm:
- For each field:
field.to_internal_value()thenfield.run_validators() - Serializer-level validators (
UniqueValidator,UniqueTogetherValidator, added automatically) validate_<fieldname>()if the method exists on the serializervalidate()for cross-field validation
What is not called: the model’s clean(). Even for a ModelSerializer, the clean() method defined on the model is never triggered by is_valid().
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ["title", "slug"]
serializer = ArticleSerializer(data={"title": "spam content", "slug": "test"})
serializer.is_valid() # True — model's clean() is not called
serializer.save() # Persists without executing the clean() logic
To force full_clean() execution in a serializer, call it explicitly inside validate():
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ["title", "slug"]
def validate(self, attrs):
instance = Article(**attrs)
instance.full_clean()
return attrs
Comparison table
blank / max_length | field validators | model clean() | unique constraints | |
|---|---|---|---|---|
obj.save() | ❌ | ❌ | ❌ | ❌ |
objects.create() | ❌ | ❌ | ❌ | ❌ |
bulk_create() | ❌ | ❌ | ❌ | ❌ |
full_clean() | ✅ | ✅ | ✅ | ✅ |
Form.is_valid() | ✅ | ✅ | ❌ | ❌ |
ModelForm.is_valid() | ✅ | ✅ | ✅ | ✅ |
ModelSerializer.is_valid() | ✅ | ✅ | ❌ | ✅* |
*via UniqueValidator added automatically by the ModelSerializer.
Ensuring validation in all contexts
The most straightforward solution to avoid forgetting full_clean() is to integrate it into the model’s save() via an abstract mixin:
class ValidatedModel(models.Model):
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
class Meta:
abstract = True
class Article(ValidatedModel):
title = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
This mixin has a cost: full_clean() sends an additional query to check validate_unique(). In bulk import or migration script contexts, you can pass full_clean(validate_unique=False) to reduce this cost, or bypass the mixin by calling super().save() directly.
The validation / persistence separation is intentional
Django deliberately separates validation from persistence. This separation allows the ORM to be used in contexts where full validation is undesirable: data migration scripts, fixtures, imports from already-validated upstream sources. Forcing full_clean() in every save() would make these cases difficult to handle.
The contract is explicit: call full_clean() before save() in business logic, use a ModelForm for user-facing interfaces since it calls full_clean() automatically, and in a DRF API, consciously choose whether the model’s clean() logic should be triggered via the serializer’s validate() method.
