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 :
- Signal
pre_saveenvoyé field.pre_save()appelé sur chaque champ (auto_now,auto_now_add, etc.)INSERTouUPDATESQL selon la présence d’unpk- Signal
post_saveenvoyé
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 :
- Pour chaque champ :
field.clean()qui enchaîne conversion, validation du type et validators du champ clean_<fieldname>()si la méthode existe sur le formulaireclean()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 :
- Pour chaque champ :
field.to_internal_value()puisfield.run_validators() - Validators au niveau du serializer (
UniqueValidator,UniqueTogetherValidator, ajoutés automatiquement) validate_<fieldname>()si la méthode existe sur le serializervalidate()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_length | validators champ | clean() modèle | contraintes 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.
