backend / fastapi
Pydantic — validation
Explication
Ce que vous allez apprendre
- Ajouter des contraintes de validation sur un champ avec
Field(...) - Écrire un
field_validatorpour transformer ou valider un champ isolé - Écrire un
model_validatorpour valider une relation entre plusieurs champs - Rejeter explicitement les champs inconnus d'un payload avec
extra="forbid" - Construire un modèle de sortie séparé pour ne jamais exposer un champ sensible
Dans quel contexte ?
Sur l'endpoint POST /users de app/routers/users.py, un client envoie {"username": "ab", "email": "pas-un-email", "age": 200}. Sans validation, ce payload pourrait corrompre la logique métier plus loin dans le code (un âge de 200 ans, un email inutilisable pour envoyer une confirmation). Avec un modèle Pydantic UserCreate correctement contraint, FastAPI rejette automatiquement cette requête avec une erreur 422 détaillée, avant même que la fonction de la route ne s'exécute.
Une image pour commencer
Imagine un videur à l'entrée d'une boîte de nuit, qui vérifie l'identité et l'âge de chaque personne AVANT de la laisser entrer. Pydantic joue exactement ce rôle pour les données qui arrivent dans ton application.
Il vérifie leur forme, leurs types, leurs contraintes. Et rejette poliment, avec une erreur claire, tout ce qui ne correspond pas, avant que ce code n'ait la moindre chance de corrompre ta logique métier.
Voyons d'abord le niveau de validation le plus simple : un champ isolé. Field(...) valide sa longueur, ses bornes numériques, ou son format via une regex.
Pour aller plus loin, field_validator permet une logique CUSTOM sur ce même champ. Comme normaliser un nom d'utilisateur en minuscules avant de le stocker.
Il reste un niveau de validation encore plus large : les relations ENTRE plusieurs champs. model_validator(mode="after") s'en charge, par exemple pour s'assurer qu'une date de fin est bien après une date de début.
Une fois ces trois niveaux maîtrisés, une protection souvent sous-estimée mérite d'être connue. Par défaut, Pydantic ignore silencieusement les champs inconnus envoyés dans un payload.
Ça semble inoffensif, mais ça peut masquer une vraie erreur côté client. Une faute de frappe dans un nom de champ passe alors totalement inaperçue.
extra="forbid" transforme ce silence en erreur explicite. Ça aide à détecter des bugs d'intégration tôt plutôt que de les laisser se propager silencieusement.
| Niveau de validation | Outil | Exemple |
|---|---|---|
| Champ isolé, contrainte simple | Field(...) | Longueur, bornes numériques, regex |
| Champ isolé, logique custom | field_validator | Normaliser un username en minuscules |
| Relation entre plusieurs champs | model_validator(mode="after") | end_date après start_date |
Il reste un pont important à connaître entre la base de données et l'API. from_attributes=True permet de construire directement un modèle Pydantic à partir d'un objet SQLAlchemy.
Ce point de jonction reviendra concrètement dans les leçons sur SQLAlchemy plus loin dans ce cours. Pour l'instant, retiens simplement qu'il existe.
Piège de sécurité
Créer un modèle de SORTIE séparé du modèle de base de données, comme UserOut, permet de ne JAMAIS exposer un champ sensible (un mot de passe hashé, par exemple) dans une réponse API, même par accident. Ne réutilise jamais directement ton modèle de base de données comme response_model.
Commandes & code
Pydantic — validation
from pydantic import BaseModel, EmailStr, Field, field_validator
from datetime import datetime
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
email: EmailStr
age: int = Field(ge=13, le=120)
bio: str | None = Field(default=None, max_length=500)
@field_validator("username")
@classmethod
def username_lowercase(cls, v: str) -> str:
return v.lower() # normalise avant stockage# Validation croisée entre plusieurs champs
from pydantic import model_validator
class DateRange(BaseModel):
start_date: datetime
end_date: datetime
@model_validator(mode="after")
def check_dates_order(self) -> "DateRange":
if self.end_date <= self.start_date:
raise ValueError("end_date doit être postérieure à start_date")
return self# Types spécialisés Pydantic — validation prête à l'emploi
from pydantic import BaseModel, HttpUrl, PositiveInt, PositiveFloat, conlist
class Product(BaseModel):
name: str
price: PositiveFloat
stock: PositiveInt
website: HttpUrl | None = None
tags: conlist(str, min_length=1, max_length=10) # liste de 1 à 10 éléments# model_config — comportement global du modèle
from pydantic import ConfigDict
class StrictProduct(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # trim automatique des strings
extra="forbid", # rejette les champs inconnus du payload
frozen=True, # immuable après création
)
name: str
price: float# Sérialisation contrôlée — exclure des champs sensibles à la réponse
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
# PAS de champ "hashed_password" ici : jamais exposé côté API
model_config = ConfigDict(from_attributes=True) # permet la conversion depuis un objet ORM
# usage avec un objet SQLAlchemy
# user_out = UserOut.model_validate(db_user)# Utilisation directe (hors endpoint) — utile pour comprendre les erreurs de validation
from pydantic import ValidationError
try:
UserCreate(username="ab", email="pas-un-email", age=200)
except ValidationError as e:
print(e.errors())
# [{'type': 'string_too_short', 'loc': ('username',), ...},
# {'type': 'value_error', 'loc': ('email',), ...},
# {'type': 'less_than_equal', 'loc': ('age',), ...}]Résumé
Field(...)ajoute des contraintes (longueur, bornes numériques, regex) directement sur les types.field_validatorvalide/transforme un champ isolé,model_validator(mode="after")valide entre plusieurs champs.extra="forbid"rejette les champs inattendus — utile pour détecter des erreurs client tôt.from_attributes=Truepermet de construire un modèle Pydantic directement depuis un objet ORM SQLAlchemy.
Exercices pratiques
Mission : le compte utilisateur invalide qui passe quand même
Objectif : Corriger un modèle Pydantic UserCreate trop permissif qui laisse passer des payloads incohérents sur POST /users.
Contexte
Sur POST /users de app/routers/users.py, un client a réussi à créer un compte avec {"username": "ab", "email": "pas-un-email", "age": 200, "role": "superadmin"}. Le champ role n'existe même pas dans le modèle mais n'a provoqué aucune erreur, et un age de 200 ans a été accepté tel quel.