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:

  1. pre_save signal sent
  2. field.pre_save() called on each field (auto_now, auto_now_add, etc.)
  3. INSERT or UPDATE SQL based on whether a pk exists
  4. post_save signal 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:

  1. For each field: field.clean() which chains type conversion, type validation, and field validators
  2. clean_<fieldname>() if the method exists on the form
  3. clean() 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:

  1. For each field: field.to_internal_value() then field.run_validators()
  2. Serializer-level validators (UniqueValidator, UniqueTogetherValidator, added automatically)
  3. validate_<fieldname>() if the method exists on the serializer
  4. validate() 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_lengthfield validatorsmodel 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.