À quoi ça sert

En Python, les annotations de type (name: str, age: int) sont par défaut purement informatives — elles ne sont pas vérifiées à l'exécution. Si tu reçois un dict JSON depuis une API et que l'âge arrive en "42" (string) au lieu de 42 (int), Python s'en moque.

Pydantic comble exactement ce trou : on définit une classe qui hérite de BaseModel, et à chaque fois qu'on crée une instance, les données sont validées et converties selon les types déclarés. Si quelque chose cloche, Pydantic lève une erreur claire listant tous les problèmes.

  • Validation à l'entrée — corps de requête d'une API, fichier de config, payload Kafka, etc.
  • Conversion automatique"42" devient 42 si le champ est int.
  • Documentation auto — la classe sert de schéma, lisible par FastAPI pour générer le OpenAPI / Swagger.
  • Settings d'applicationBaseSettings lit les variables d'environnement et les fichiers .env.
  • Sérialisation.model_dump() reconvertit en dict ou JSON.
Pydantic vs dataclass ?

Une @dataclass Python définit une classe avec des champs typés, mais ne valide rien à l'exécution. Pydantic fait en plus la validation, la conversion, la sérialisation, et la gestion d'erreurs structurée. Pour de la config interne sans entrée externe, une dataclass suffit. Dès que les données viennent d'une source non maîtrisée (HTTP, fichier, env), Pydantic devient indispensable.

Un exemple d'usage

Tu reçois en JSON la fiche d'un utilisateur depuis une API externe. Tu veux être sûr que les champs sont bien là et bien typés avant de continuer :

python
from pydantic import BaseModel, EmailStr, Field

class User(BaseModel):
    id: int
    name: str
    email: EmailStr
    age: int = Field(ge=0, le=120)
    is_active: bool = True

# Cas normal : tout passe
data = {"id": "42", "name": "Alice",
        "email": "a@b.fr", "age": 30}
user = User(**data)
print(user.id)         # 42 (int, pas string)
print(user.model_dump())  # dict prêt à sérialiser

# Cas invalide : Pydantic lève une erreur lisible
User(id=1, name="Bob", email="pas-un-email", age=200)
# ValidationError: 2 validation errors for User
#   email: value is not a valid email address
#   age: Input should be less than or equal to 120

En 6 lignes, tu as déclaré un schéma typé, validé l'entrée, converti "42" en 42, vérifié l'email, contraint l'âge entre 0 et 120. Et tu peux ressortir un dict propre avec model_dump().

How-to : installer et utiliser Pydantic

  1. Installer Pydantic

    Dépendance principale (cf. UV) — pour la validation d'email il faut un extra :

    bash
    uv add "pydantic[email]"

    Pour BaseSettings (lecture de fichiers .env), il faut un paquet séparé depuis Pydantic v2 :

    bash
    uv add pydantic-settings
    Pydantic v1 vs v2

    Depuis 2023, la version 2 a réécrit le moteur en Rust (×5 plus rapide) et changé quelques API : .dict().model_dump(), .parse_obj().model_validate(). Cette fiche couvre v2. Si tu tombes sur du vieux code, vérifie la version.

  2. Définir un modèle de base

    On hérite de BaseModel et on déclare les champs avec des annotations standard :

    python
    from pydantic import BaseModel
    
    class Product(BaseModel):
        name: str
        price: float
        in_stock: bool = True      # valeur par défaut
    
    p = Product(name="Stylo", price="2.50")
    print(p.price)            # 2.5 (converti en float)
    print(p.model_dump())     # {'name': 'Stylo', 'price': 2.5, ...}
    print(p.model_dump_json()) # '{"name":"Stylo","price":2.5,...}'
  3. Types riches et contraintes

    Pydantic comprend tous les types Python standards (incl. list, dict, Optional, Literal) et fournit ses propres types validés :

    python
    from datetime import datetime
    from typing import Literal
    from pydantic import BaseModel, EmailStr, HttpUrl, Field
    
    class Article(BaseModel):
        title: str = Field(min_length=3, max_length=100)
        price: float = Field(gt=0)         # > 0
        tags: list[str] = []
        status: Literal["draft", "published"] = "draft"
        url: HttpUrl
        author_email: EmailStr
        created: datetime = Field(default_factory=datetime.now)
  4. Validateurs personnalisés

    Quand un type ne suffit pas, on écrit une fonction de validation avec le décorateur @field_validator :

    python
    from pydantic import BaseModel, field_validator
    
    class SignupForm(BaseModel):
        username: str
        password: str
    
        @field_validator("username")
        @classmethod
        def no_spaces(cls, v: str) -> str:
            if " " in v:
                raise ValueError("pas d'espaces dans le username")
            return v.lower()      # on peut aussi normaliser
  5. Gérer les erreurs de validation

    python
    from pydantic import ValidationError
    
    try:
        user = User(id="abc", name="Alice", email="x", age=200)
    except ValidationError as e:
        print(e.errors())   # liste structurée des erreurs
        print(e.json())     # même chose en JSON, prêt à logger

    e.errors() renvoie une liste de dicts (champ, message, type d'erreur, valeur reçue) — idéal pour rendre une réponse 422 propre dans une API.

  6. Settings d'application avec BaseSettings

    BaseSettings lit automatiquement les variables d'environnement et les fichiers .env. C'est la façon propre de configurer une application en MLOps :

    python
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env")
    
        database_url: str
        api_key: str
        debug: bool = False
    
    settings = Settings()
    print(settings.database_url)

    Avec un fichier .env contenant DATABASE_URL=postgresql://..., Pydantic injecte la valeur. Pratique pour passer du local au déploiement (Docker, GitHub Actions…) sans changer une ligne de code.

  7. Sérialiser et désérialiser

    python
    # Depuis un dict
    user = User.model_validate({"id": 1, "name": "…"})
    
    # Depuis du JSON brut
    user = User.model_validate_json('{"id": 1, "name": "…"}')
    
    # Vers un dict
    user.model_dump()
    user.model_dump(exclude={"password"})
    
    # Vers du JSON (string)
    user.model_dump_json(indent=2)

Aide-mémoire

python (modèle)
from pydantic import BaseModel, Field, EmailStr, HttpUrl

class M(BaseModel):
    x: int = Field(ge=0)
    name: str = Field(min_length=1)
    tags: list[str] = []
    email: EmailStr
python (validation)
obj = M(**data)
obj = M.model_validate(data)
obj = M.model_validate_json("{...}")
obj.model_dump()
obj.model_dump_json()
python (validateur)
from pydantic import field_validator

@field_validator("x")
@classmethod
def check(cls, v):
    if v < 0: raise ValueError("…")
    return v
python (settings)
from pydantic_settings import BaseSettings, SettingsConfigDict

class S(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    api_key: str

Pydantic et le reste de l'écosystème

  • FastAPI — c'est ici que Pydantic brille le plus. Tout corps de requête, paramètre, modèle de réponse est un BaseModel. FastAPI valide automatiquement, rend des erreurs 422 propres, et génère la doc Swagger à partir des classes.
  • MLflow — typer le signature d'un modèle (inputs/outputs) avec Pydantic permet de garantir que le serving reçoit les bonnes données. La validation évite des bugs silencieux en production.
  • SQLAlchemy — Pydantic et SQLAlchemy coexistent : SQLAlchemy gère la base, Pydantic les DTO/schémas d'API. La lib SQLModel (du créateur de FastAPI) fusionne les deux quand on veut éviter la duplication.
  • logging / Loguru — au lieu de logger des dicts bruts, on logge obj.model_dump_json() : structure garantie, recherche facile dans Loki.
  • Variables d'environnement — avec BaseSettings, plus besoin de jongler entre os.environ.get() et des valeurs par défaut éparpillées. Tout est typé et centralisé dans une classe.
Le piège des types facultatifs

Beaucoup confondent x: int = None (interdit en Pydantic v2, car incohérent) avec x: int | None = None (autorisé : champ optionnel). Si tu veux qu'un champ puisse manquer, sois explicite avec | None ou Optional[int], ET donne une valeur par défaut.

Pour aller plus loin