Appeler obj.save() après avoir défini des validators et un clean() sur le modèle laisse croire que la validation est garantie. Elle ne l’est pas. Django ne déclenche pas full_clean() lors d’un save(), et ce comportement est délibéré. Comprendre pourquoi change la façon d’architecturer la validation dans un projet.

Ce que save() fait réellement

Le cycle de vie d’un save() est plus court que ce qu’on imagine :

  1. Signal pre_save envoyé
  2. field.pre_save() appelé sur chaque champ (auto_now, auto_now_add, etc.)
  3. INSERT ou UPDATE SQL selon la présence d’un pk
  4. Signal post_save envoyé

Aucune validation n’y figure. Ni vérification de blank, ni max_length, ni appel à clean(). C’est aussi vrai pour Model.objects.create() et Model.objects.bulk_create() : les trois méthodes persistent sans valider. (bulk_create() est traité en détail dans Django in_bulk() et bulk_create() si vous travaillez sur des insertions en masse.)

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("Titre invalide.")

# Ces trois appels passent sans erreur, même avec un titre à 500 caractères
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() : les quatre étapes

full_clean() est la méthode à appeler explicitement pour déclencher toute la chaîne de validation d’un modèle. Elle s’exécute en quatre étapes dans cet ordre :

1. clean_fields() valide chaque champ individuellement : conversion vers le type Python (to_python()), vérification de blank, null, max_length, choices, puis les validators définis sur le champ.

2. clean() est la méthode de validation inter-champs, à surcharger sur le modèle. C’est ici qu’on exprime des règles qui dépendent de plusieurs champs à la fois.

3. validate_unique() vérifie les contraintes d’unicité : champs unique=True, unique_together, et UniqueConstraint déclarées sans condition.

4. validate_constraints() vérifie les Meta.constraints plus complexes (CheckConstraint, UniqueConstraint avec condition, 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("La date de fin doit être après la date de début.")

event = Event(name="Conf", start_date="2026-06-10", end_date="2026-06-01")
event.full_clean()  # Lève ValidationError depuis clean()
event.save()        # N'est jamais atteint

Les erreurs remontées par full_clean() sont des ValidationError. Si plusieurs champs ont des erreurs, elles sont toutes collectées avant d’être levées ensemble dans un seul dictionnaire.

form.is_valid() : deux comportements selon le type de formulaire

BaseForm : pipeline propre, sans le modèle

Un formulaire standard (forms.Form) a son propre pipeline de validation, indépendant de tout modèle Django :

  1. Pour chaque champ : field.clean() qui enchaîne conversion, validation du type et validators du champ
  2. clean_<fieldname>() si la méthode existe sur le formulaire
  3. clean() du formulaire pour la validation inter-champs

Le modèle Django n’est pas impliqué.

ModelForm : il appelle full_clean() du modèle

C’est le point qui surprend. Un ModelForm fait plus qu’un Form. Après avoir validé ses propres champs, il appelle instance.full_clean(exclude=..., validate_unique=False) dans sa méthode _post_clean().

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() a appelé article_instance.full_clean()
    # clean() du modèle a donc été exécuté
    form.save()

Concrètement, si le modèle définit un clean() qui lève une ValidationError, cette erreur sera capturée et intégrée aux erreurs du formulaire. Le paramètre validate_unique=False dans l’appel vient du fait que le ModelForm gère l’unicité séparément via sa propre méthode validate_unique().

Les validators définis sur les champs du modèle sont aussi copiés automatiquement dans les champs du ModelForm correspondants.

serializer.is_valid() dans DRF : pipeline propre, pas de full_clean()

Un ModelSerializer a un pipeline de validation distinct, plus proche d’un Form standard que d’un ModelForm :

  1. Pour chaque champ : field.to_internal_value() puis field.run_validators()
  2. Validators au niveau du serializer (UniqueValidator, UniqueTogetherValidator, ajoutés automatiquement)
  3. validate_<fieldname>() si la méthode existe sur le serializer
  4. validate() pour la validation inter-champs

Ce qui n’est pas appelé : clean() du modèle. Même pour un ModelSerializer, la méthode clean() définie sur le modèle n’est jamais déclenchée par 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 — clean() du modèle n'est pas appelé
serializer.save()      # Persiste sans avoir exécuté la logique de clean()

Pour forcer l’exécution de full_clean() dans un serializer, il faut le faire explicitement dans validate() :

class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = ["title", "slug"]

    def validate(self, attrs):
        instance = Article(**attrs)
        instance.full_clean()
        return attrs

Comparatif

blank / max_lengthvalidators champclean() modèlecontraintes unique
obj.save()
objects.create()
bulk_create()
full_clean()
Form.is_valid()
ModelForm.is_valid()
ModelSerializer.is_valid()✅*

*via UniqueValidator ajouté automatiquement par le ModelSerializer.

Garantir la validation dans tous les contextes

La solution la plus directe pour éviter d’oublier full_clean() est de l’intégrer dans save() du modèle via un mixin abstrait :

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)

Ce mixin a un coût : full_clean() envoie une requête supplémentaire pour vérifier validate_unique(). Dans les contextes d’import massif ou de scripts de migration, on peut passer full_clean(validate_unique=False) pour limiter ce coût, ou désactiver ponctuellement le mixin en appelant directement super().save().

La séparation validation / persistance est intentionnelle

Django sépare délibérément validation et persistance. Cette séparation permet à l’ORM d’être utilisé dans des contextes où la validation complète n’est pas souhaitable : scripts de migration de données, fixtures, imports depuis des sources déjà validées en amont. Imposer full_clean() dans tous les save() aurait rendu ces cas difficiles à gérer.

Le contrat est explicite : appeler full_clean() avant save() dans le code métier, utiliser un ModelForm pour les interfaces utilisateur car il appelle full_clean() automatiquement, et dans une API DRF, choisir consciemment si la logique du clean() modèle doit être déclenchée via validate() du serializer.