C’est une question que chaque équipe Python se pose tôt ou tard. Les réponses qu’on trouve en ligne sont souvent du type "Pydantic pour les API, dataclasses pour le reste" — ce qui est un début, mais ne couvre pas les cas limites où ce choix a un impact réel. Voici les règles de décision que j’applique en pratique.

Comprendre ce que chacun fait réellement

Avant les règles, un rappel sur ce que chaque outil résout :

TypedDict est une annotation de type pure. Il ne fait rien à l’exécution — il informe juste le type checker (mypy, pyright) sur la forme d’un dictionnaire. Aucun coût à l’exécution, aucune validation.

Dataclass génère des classes Python standard : __init__, __repr__ et __eq__ sont créés automatiquement à partir des annotations. Pas de validation des types à l’exécution.

Pydantic BaseModel est un système de validation complet. Il convertit et valide les données à l’exécution, lève des erreurs détaillées, sérialise/désérialise JSON, et génère des schémas JSON Schema.

Ce ne sont pas trois façons de faire la même chose — ce sont trois outils avec des responsabilités différentes.

Règle 1 : TypedDict pour les dictionnaires dont on ne contrôle pas la création

from typing import TypedDict

# Données retournées par une API externe — tu ne les construis pas, tu les lis
class GrxCertificate(TypedDict):
    id: str
    volume: float
    period_from: str
    period_to: str
    status: str

# Usage
def process_certificate(cert: GrxCertificate) -> float:
    return cert["volume"] * 1.05  # Type checker valide l'accès

TypedDict est idéal pour typer des dictionnaires qui viennent de l’extérieur (réponses JSON, résultats de requêtes SQL, configs YAML) sans les transformer en objets. L’overhead à l’exécution est nul — c’est purement statique.

La limite : TypedDict ne valide rien à l’exécution. Si l’API renvoie un volume sous forme de chaîne de caractères, le code plante plus loin, pas à la désérialisation.

Règle 2 : Dataclass pour les modèles internes sans validation

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class CertificateAggregate:
    account_id: str
    total_volume: float
    certificate_count: int
    computed_at: datetime = field(default_factory=datetime.now)

    def average_volume(self) -> float:
        if self.certificate_count == 0:
            return 0.0
        return self.total_volume / self.certificate_count

Les dataclasses sont parfaites pour les objets que l’on construit soi-même dans le code métier — résultats d’agrégation, objets intermédiaires de traitement, value objects du domaine. Elles sont plus légères que Pydantic et plus explicites que des dictionnaires.

@dataclass(frozen=True) les rend immuables — utile pour les value objects :

@dataclass(frozen=True)
class DateRange:
    start: str
    end: str

    def __post_init__(self):
        if self.start > self.end:
            raise ValueError(f"start ({self.start}) doit être avant end ({self.end})")

__post_init__ permet d’ajouter de la validation sans Pydantic — suffisant pour des invariants simples.

Règle 3 : Pydantic pour tout ce qui touche les frontières du système

from pydantic import BaseModel, Field, field_validator
from typing import Literal

class CertificateRequest(BaseModel):
    account_id: str = Field(min_length=3, max_length=50)
    volume: float = Field(gt=0, description="Volume en MWh")
    technology: Literal["WIND", "SOLAR", "HYDRO", "BIOMASS"]
    period_from: str
    period_to: str

    @field_validator("period_to")
    @classmethod
    def period_to_after_from(cls, v: str, info) -> str:
        if "period_from" in info.data and v <= info.data["period_from"]:
            raise ValueError("period_to doit être postérieure à period_from")
        return v

class CertificateResponse(BaseModel):
    id: str
    volume: float
    status: Literal["ACTIVE", "CANCELLED", "TRANSFERRED"]

    model_config = {"from_attributes": True}  # Compatibilité avec les ORM

Pydantic gagne sur toutes les frontières : entrées HTTP (corps de requête, paramètres), sorties JSON, lecture de configuration, lecture de variables d’environnement.

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str
    secret_key: str
    debug: bool = False

    model_config = {"env_file": ".env"}

settings = Settings()  # Lève une erreur claire si DATABASE_URL est absente

pydantic-settings est particulièrement utile — il lit les variables d’environnement, les convertit dans les bons types, et lève des erreurs explicites au démarrage si une variable obligatoire est manquante.

Les performances : quand ça compte vraiment

Pydantic v2 (réécrit en Rust) est considérablement plus rapide que v1, mais reste plus lent que les dataclasses pour la construction d’objets :

OutilConstruction (relative)ValidationSérialisation JSON
TypedDict1xAucuneManuel
Dataclass1.2x__post_init__dataclasses.asdict()
Pydantic v23-5xComplète.model_dump_json()

Sur un endpoint FastAPI qui traite 1 000 requêtes/seconde avec des modèles simples, la différence Pydantic vs dataclass est négligeable. Elle devient visible sur des pipelines de traitement qui instancient des millions d’objets — ETL, traitement de fichiers volumineux.

La règle pratique : n’optimise pas prématurément. Pydantic aux frontières, dataclasses en interne — cette séparation donne de bonnes performances par défaut sans micro-optimisation.

Combiner les trois

Sur un projet réel, les trois coexistent naturellement :

from typing import TypedDict
from dataclasses import dataclass
from pydantic import BaseModel

# TypedDict : réponse brute de l'API externe
class RawApiResponse(TypedDict):
    data: list[dict]
    meta: dict

# Pydantic : validation et parsing de la réponse
class Certificate(BaseModel):
    id: str
    volume: float
    status: str

# Dataclass : objet métier interne après traitement
@dataclass
class CertificateReport:
    total_volume: float
    active_count: int
    cancelled_count: int

def process_response(raw: RawApiResponse) -> CertificateReport:
    certificates = [Certificate.model_validate(item) for item in raw["data"]]
    active = [c for c in certificates if c.status == "ACTIVE"]
    cancelled = [c for c in certificates if c.status == "CANCELLED"]
    return CertificateReport(
        total_volume=sum(c.volume for c in certificates),
        active_count=len(active),
        cancelled_count=len(cancelled),
    )

Chaque outil à sa place : TypedDict pour l’externe brut, Pydantic pour la validation à la frontière, dataclass pour la logique interne. C’est cette séparation qui rend le code lisible et maintenable à long terme.