Pydantic v2 est devenu en 2026 le standard de validation et de sérialisation pour Python moderne. La version 2.13.4 sortie le 6 mai 2026, propulsée par pydantic-core écrit en Rust, valide des objets cinq à dix fois plus vite que la v1 et alimente l’écosystème : FastAPI, SQLModel, BentoML, Pydantic Settings, configuration de pipelines ML. Pour quitter le terrain glissant du « je valide à la main avec des if » et passer à des modèles déclaratifs propres, plusieurs idées structurent l’usage : modèles BaseModel, types contraints, validators, sérialisation par mode, settings depuis les variables d’environnement, et compatibilité dataclass. Ce tutoriel les enchaîne pas à pas.
Prérequis
- Python 3.13.13 ou 3.14.5 (cf. Installer Python 3)
- Notions de type hints (
list[int],dict[str, X],Optional) - Temps estimé : 75 minutes
Étape 1 — Installer Pydantic v2 et premier modèle
Pydantic v2 s’installe en une commande et fonctionne sans configuration. Le modèle de base, BaseModel, accepte des annotations de type Python standard et fournit gratuitement la validation, la coercion (str→int si possible), la sérialisation JSON, et la génération de schéma JSON Schema.
uv add pydantic
# ou
pip install --upgrade "pydantic>=2.13"
# Premier modèle
from pydantic import BaseModel, Field
from datetime import datetime
class Utilisateur(BaseModel):
id: int
email: str
nom: str = Field(min_length=2, max_length=100)
actif: bool = True
cree_le: datetime = Field(default_factory=datetime.now)
# Validation
user = Utilisateur(id=42, email="alice@example.com", nom="Alice")
print(user.model_dump())
# {'id': 42, 'email': 'alice@example.com', 'nom': 'Alice', 'actif': True, 'cree_le': datetime(...)}
Trois aspects à comprendre. Les annotations de type ne sont plus décoratives — Pydantic les utilise activement pour valider. Field(min_length=2, max_length=100) ajoute des contraintes sans changer le type apparent. default_factory appelle la fonction à chaque création (vs default=datetime.now() qui figerait la valeur au moment de l’import). Le model_dump() remplace l’ancien .dict() de v1.
Étape 2 — Types contraints et validation stricte
Pydantic v2 livre une bibliothèque riche de types contraints qui évitent d’écrire des validators manuels pour les cas courants : EmailStr, HttpUrl, PositiveInt, conint, constr, Annotated avec Field.
from pydantic import BaseModel, EmailStr, HttpUrl, PositiveInt, Field
from typing import Annotated
# Annotated permet de combiner type + contraintes
Age = Annotated[int, Field(ge=0, le=150)]
Slug = Annotated[str, Field(pattern=r"^[a-z0-9-]+$", max_length=80)]
class Auteur(BaseModel):
email: EmailStr
site: HttpUrl
age: Age
slug: Slug
nb_articles: PositiveInt
biographie: Annotated[str, Field(max_length=500)] | None = None
# Validation stricte (refuse les conversions implicites)
class Config(BaseModel):
model_config = {"strict": True}
port: int # refuse "8080" (str), accepte 8080 (int)
Le mode strict est précieux pour les APIs sensibles : il refuse les coercions implicites qui peuvent masquer des bugs (par exemple, un "true" string accepté comme True bool). Pour un projet typique, le mode permissif par défaut convient pour les payloads HTTP ; le mode strict pour la configuration interne où la rigueur prime sur la souplesse.
Étape 3 — Validators field et model
Quand les contraintes builtin ne suffisent pas, on écrit un validator. Pydantic v2 distingue deux décorateurs principaux : @field_validator pour valider un champ après que son type a été coercé, et @model_validator pour valider l’objet entier (utile pour des règles croisées entre champs).
from pydantic import BaseModel, field_validator, model_validator
from datetime import datetime, timezone
class Evenement(BaseModel):
debut: datetime
fin: datetime
capacite: int
@field_validator("debut", "fin", mode="after")
@classmethod
def doit_etre_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("Datetime doit être timezone-aware (UTC ou autre)")
return v.astimezone(timezone.utc)
@model_validator(mode="after")
def fin_apres_debut(self) -> "Evenement":
if self.fin <= self.debut:
raise ValueError("La fin doit être strictement après le début")
return self
Les mode détermine l’ordre d’exécution. mode="before" reçoit la valeur brute avant coercion (utile pour transformer des formats exotiques). mode="after" reçoit la valeur après coercion vers le type cible (le défaut, plus simple). Le model_validator mode="after" reçoit l’instance complète, ce qui permet de vérifier les invariants inter-champs.
Étape 4 — Sérialisation et model_dump
La sérialisation Pydantic v2 propose trois méthodes principales : model_dump() renvoie un dict Python, model_dump_json() renvoie une chaîne JSON optimisée (via pydantic-core en Rust, beaucoup plus rapide que json.dumps(obj.model_dump())), et model_validate_json() désérialise depuis une chaîne JSON en validant.
user = Utilisateur(id=42, email="alice@example.com", nom="Alice")
# Vers dict (utile pour DB ORM)
data = user.model_dump()
# Vers JSON string (utile pour HTTP)
payload = user.model_dump_json(indent=2)
# Exclure des champs sensibles
public = user.model_dump(exclude={"email"})
# Inclure seulement certains champs
minimal = user.model_dump(include={"id", "nom"})
# Champs avec alias (pour APIs externes)
class UserDto(BaseModel):
id_externe: int = Field(alias="external_id")
nom_complet: str = Field(alias="full_name")
model_config = {"populate_by_name": True}
# Parsing JSON avec alias
dto = UserDto.model_validate_json('{"external_id": 1, "full_name": "Bob"}')
# Sérialisation avec alias
print(dto.model_dump_json(by_alias=True))
# {"external_id": 1, "full_name": "Bob"}
L’aliasing est crucial quand on traduit entre un schéma snake_case interne et un schéma camelCase externe (typique des APIs Java/Node). populate_by_name=True permet d’accepter les deux formes en entrée. Cette flexibilité évite de polluer les noms internes Python avec des conventions étrangères.
Étape 5 — Pydantic Settings : configuration depuis les variables d’environnement
pydantic-settings est le module officiel (séparé) pour charger la configuration d’un service depuis les variables d’environnement, des fichiers .env, des secrets Docker ou Vault. Il valide les types comme tout BaseModel et offre une expérience nettement supérieure à os.environ brut.
uv add pydantic-settings
from pydantic import Field, HttpUrl, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Reglages(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="APP_",
case_sensitive=False,
extra="forbid"
)
nom_service: str = "api-itsc"
debug: bool = False
database_url: str
redis_url: str = "redis://localhost:6379/0"
secret_key: SecretStr # ne sera pas loggé en clair
workers: int = Field(default=4, ge=1, le=64)
api_base: HttpUrl
# Usage
reglages = Reglages()
print(reglages.nom_service)
print(reglages.secret_key.get_secret_value()) # accès explicite à la valeur
Trois éléments structurants. SecretStr masque la valeur dans les repr() et les logs (affiche '**********'), ce qui prévient des fuites accidentelles. env_prefix="APP_" mappe automatiquement APP_DATABASE_URL → database_url. extra="forbid" refuse les variables d’environnement non déclarées, ce qui détecte les typos de configuration tôt. Pour un service Kubernetes, on combine fichier .env local pour le dev et variables d’environnement injectées en prod, sans changer le code.
Étape 6 — Generic models et héritage
Pydantic v2 supporte les modèles génériques via TypeVar et Generic. C’est particulièrement utile pour standardiser les réponses d’API qui enveloppent des données de types variés (pagination, enveloppes JSON-API, résultats unifiés).
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Reponse(BaseModel, Generic[T]):
succes: bool
donnees: T | None = None
erreur: str | None = None
request_id: str
class Article(BaseModel):
id: int
titre: str
# Spécialisation au moment de l'usage
reponse: Reponse[Article] = Reponse[Article](
succes=True,
donnees=Article(id=1, titre="Hello"),
request_id="req-abc"
)
# Réponse paginée générique
class Pagination(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
par_page: int
Le type checker (mypy, pyright) comprend ces génériques et vérifie statiquement que reponse.donnees a bien le type Article | None. À l’exécution, Pydantic valide réellement la structure imbriquée. Combiner statique et runtime donne un filet de sécurité solide qu’on n’a pas avec des dicts.
Étape 7 — Intégration FastAPI
FastAPI dépend directement de Pydantic et utilise vos modèles comme schémas de requête/réponse, avec génération automatique de la documentation OpenAPI/Swagger. Les payloads sont validés à l’entrée, sérialisés en sortie, et documentés gratuitement.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UtilisateurCreation(BaseModel):
email: EmailStr
nom: str
age: int = Field(ge=0, le=150)
class UtilisateurReponse(BaseModel):
id: int
email: EmailStr
nom: str
cree_le: datetime
@app.post("/users", response_model=UtilisateurReponse, status_code=201)
async def creer_user(payload: UtilisateurCreation) -> UtilisateurReponse:
user = await db.creer_utilisateur(**payload.model_dump())
return user
@app.get("/users/{user_id}", response_model=UtilisateurReponse)
async def lire_user(user_id: int) -> UtilisateurReponse:
user = await db.lire_utilisateur(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Utilisateur introuvable")
return user
Le response_model filtre automatiquement les champs sensibles que vous ne voulez pas exposer (un password_hash dans le modèle DB n’apparaîtra jamais dans la réponse si pas dans UtilisateurReponse). Cette discipline évite les fuites de données et documente l’API précisément. Pour les tests de cette stack, voir le tutoriel pytest avancé.
Étape 8 — Sérialisation customisée et computed fields
Pour des cas exotiques, Pydantic v2 expose @field_serializer et @computed_field. Le premier customise comment un champ est sérialisé (formats de date sur mesure, masquage). Le second expose une valeur calculée comme s’il s’agissait d’un champ persisté.
from pydantic import BaseModel, field_serializer, computed_field
from datetime import datetime, timezone
class Article(BaseModel):
titre: str
contenu: str
publie_le: datetime
@field_serializer("publie_le")
def serialize_date(self, v: datetime, _info) -> str:
return v.astimezone(timezone.utc).isoformat()
@computed_field
@property
def nb_mots(self) -> int:
return len(self.contenu.split())
@computed_field
@property
def duree_lecture_min(self) -> int:
return max(1, self.nb_mots // 200)
Les computed fields apparaissent dans model_dump() et la documentation OpenAPI comme des champs normaux. Cela évite de calculer ces dérivés côté client ou de polluer le modèle DB avec des colonnes redondantes. La cohérence est garantie : on lit toujours la valeur calculée depuis le même source de vérité (le contenu).
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| ValidationError obscure sur champ optionnel | Type Optional sans None par défaut | Utiliser x: int | None = None |
| ValidationError sur ISO datetime | Format non standard | Pré-traiter avec field_validator(mode="before") |
| Type str converti en int silencieusement | Mode permissif par défaut | Activer strict: True dans model_config |
| Champ alias non reconnu en POST | populate_by_name absent | Ajouter populate_by_name: True |
| Secret loggé en clair | str au lieu de SecretStr | Utiliser SecretStr + .get_secret_value() |
| JSON Schema incomplet | Validators manuels non documentés | Utiliser Annotated[Field] plutôt que validators custom quand possible |
Foire aux questions
Pydantic v1 ou v2 ?
v2 pour tout nouveau projet. v1 reçoit seulement des correctifs de sécurité depuis 2024. Une migration v1→v2 est documentée par l’outil bump-pydantic.
Performance vs dataclasses ?
Pydantic v2 valide à chaque création (coût), les dataclasses ne valident jamais (gratuit mais danger). En 2026 avec pydantic-core en Rust, le coût Pydantic est négligeable (~10 μs par modèle simple).
SQLModel ou Pydantic + SQLAlchemy séparés ?
SQLModel pour un projet simple avec une seule représentation. Modèles séparés (Pydantic pour HTTP, SQLAlchemy pour DB) pour les projets complexes où schéma API ≠ schéma DB.
Comment valider des nested models ?
Native : auteurs: list[Auteur] dans un BaseModel valide récursivement chaque auteur.
Comment générer le JSON Schema ?Utilisateur.model_json_schema() renvoie le JSON Schema complet, conforme draft 2020-12, exploitable par OpenAPI ou des validateurs externes.
Pour aller plus loin
La validation maîtrisée, l’étape suivante consiste à industrialiser la qualité de code avec Ruff et uv, puis structurer la distribution avec pyproject.toml et le packaging. Pour la vue panoramique, voir le guide principal Python.