Développement Web

Pydantic v2 en 2026 : validation, settings et intégration FastAPI

10 min de lecture

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_URLdatabase_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.

Ressources et références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité