Pydantic v2 : valider les données d'entrée en Python

1. Aperçu

Les données provenant de requêtes HTTP, de fichiers JSON ou de files de messages présentent souvent des erreurs de type, des champs manquants ou des valeurs aberrantes. Si vous éparpillez des vérifications if/else un peu partout, le code devient rapidement confus. Pydantic v2 vous permet de décrire la « forme » des données grâce aux annotations de type, puis de parser, convertir les types et signaler les erreurs automatiquement de manière détaillée.

Cet article explore les outils principaux de Pydantic v2 : BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. La dernière partie montre comment écrire des tests avec pytest. Après lecture, vous serez en mesure de construire vous-même des modèles de validation d’entrée sûrs pour votre application.

2. Installation

Cet article utilise Python 3.11+ et Pydantic 2.x :

pip install "pydantic>=2.7" pytest

Si vous avez besoin du type EmailStr, installez le supplément correspondant :

pip install "pydantic[email]"

3. Modèle de base avec BaseModel

3.1. Déclaration et conversion de type

Un modèle est une classe héritant de BaseModel. Chaque attribut avec une annotation de type est un champ :

from pydantic import BaseModel

class Customer(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True

customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>

Vous passez id="42" sous forme de chaîne, mais Pydantic le convertit automatiquement en int. C’est le lax mode, le mode par défaut : Pydantic convertit les types si la conversion est raisonnable. Le champ is_active ayant une valeur par défaut, il n’est pas obligatoire de le transmettre.

3.2. Gestion de ValidationError

Quand les données sont incorrectes, Pydantic lève ValidationError et rassemble tous les erreurs à la fois, sans s’arrêter à la première :

from pydantic import ValidationError

try:
    Customer(id="abc", name="An")
except ValidationError as e:
    print(e.error_count())
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required

Chaque erreur est un dict contenant loc (position du champ), type (code d’erreur stable) et msg (message pour le lecteur). Lorsque vous signalez une erreur au client, vous devez vous appuyer sur loc et type. Le contenu de msg peut changer entre les versions.

4. Contraintes de valeur avec Field et Annotated

Les annotations de type vérifient uniquement le type. Pour limiter les valeurs, vous utilisez Field :

from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field

Quantity = Annotated[int, Field(gt=0, le=100)]

class OrderItem(BaseModel):
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    quantity: Quantity
    unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')

Explications :

  • pattern oblige sku à correspondre à la regex, par exemple ABC-1234.
  • Quantity est un type réutilisable grâce à Annotated. Tout champ déclaré en tant que Quantity doit se situer dans la plage (0, 100].
  • unit_price utilise Decimal pour éviter les erreurs de précision en virgule flottante. La valeur doit être positive et avoir au maximum 2 décimales.

Essayez de passer des données incorrectes :

try:
    OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than

Quelques autres contraintes courantes : min_length/max_length pour les chaînes et listes, ge/lt pour les nombres, multiple_of pour les multiples.

5. Validateurs personnalisés avec field_validator

Quand les contraintes sont plus complexes qu’une regex ou une plage de valeurs, vous écrivez votre propre validateur :

from pydantic import BaseModel, Field, field_validator

class Contact(BaseModel):
    name: str
    phone: str = Field(pattern=r"^0\d{9}$")

    @field_validator("name")
    @classmethod
    def clean_name(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("name ne doit pas être vide")
        return v.title()

    @field_validator("phone", mode="before")
    @classmethod
    def normalize_phone(cls, v):
        if isinstance(v, str):
            v = v.replace(" ", "").replace(".", "")
            if v.startswith("+84"):
                v = "0" + v[3:]
        return v

contact = Contact(name="  nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'

Il y a deux modes à distinguer :

  • mode="after" (par défaut), comme clean_name : s’exécute après que Pydantic a vérifié le type, donc v est sûrement une str.
  • mode="before", comme normalize_phone : s’exécute avant la vérification du type et pattern. v peut être n’importe quoi, donc vous devez vérifier isinstance. Ici, nous normalisons +84 912 345 678 en 0912345678, puis pattern est appliqué.

Le validateur doit retourner une valeur. La valeur retournée est celle qui sera sauvegardée dans le champ. Pour signaler une erreur, vous levez ValueError ou AssertionError. Pydantic l’enveloppe en tant qu’erreur value_error, avec le message "Value error, name ne doit pas être vide".

Remarque : L’ordre des décorateurs est très important. @field_validator doit être au-dessus de @classmethod. Si vous inversez, vous rencontrerez une erreur déroutante au moment de la définition de la classe.

6. Valider plusieurs champs à la fois avec model_validator

Certaines règles impliquent plusieurs champs, par exemple la date de départ doit être après la date d’arrivée. Dans ce cas, vous avez besoin de model_validator :

from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator

class Booking(BaseModel):
    room_id: int
    check_in: date
    check_out: date

    @model_validator(mode="after")
    def check_dates(self) -> Self:
        if self.check_out <= self.check_in:
            raise ValueError("check_out doit être après check_in")
        return self

try:
    Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
    err = e.errors()[0]
    print(err["loc"], err["msg"])
() Value error, check_out doit être après check_in

Avec mode="after", le validateur reçoit une instance valide pour chaque champ. Vous accédez à self.check_in comme un attribut normal et devez return self. L’erreur a loc vide () car elle concerne l’ensemble du modèle, pas un champ spécifique. La chaîne "2026-10-20" est également automatiquement analysée en tant que date.

7. Configurer le modèle avec ConfigDict

model_config contrôle le comportement global du modèle. Voici une configuration courante pour les données sensibles comme les paiements :

from pydantic import BaseModel, ConfigDict, ValidationError

class PaymentRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        strict=True,
        str_strip_whitespace=True,
        frozen=True,
    )

    order_id: int
    amount: int
    currency: str

try:
    PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden

Signification de chaque option :

  • extra="forbid" : rejette les champs supplémentaires comme coupon. Par défaut, c’est "ignore", c’est-à-dire que Pydantic ignore silencieusement les champs supplémentaires. Cela peut masquer les erreurs de typage de nom de champ côté client.
  • strict=True : désactive la conversion de type. "1" n’est plus accepté pour int.
  • str_strip_whitespace=True : supprime automatiquement les espaces à chaque extrémité de la chaîne.
  • frozen=True : l’instance est immuable. L’assignation payment.amount = 0 levera une erreur.

Si vous voulez le mode strict pour seulement quelques champs, utilisez Field(strict=True) ou des types comme StrictInt, StrictStr.

Une autre option à connaître est validate_assignment=True. Par défaut, Pydantic ne valide pas lorsque vous réassignez un attribut après la création de l’objet. Activez cette option pour garder le modèle toujours valide.

8. Valider à partir de dict, JSON et tout type

En pratique, les données arrivent souvent sous forme de dict ou de chaîne JSON. Pydantic v2 fournit deux méthodes de classe :

raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)

raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)

print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}

Si l’entrée est une chaîne JSON, vous devez utiliser model_validate_json plutôt que json.loads suivi de model_validate. C’est plus rapide car le cœur Rust de Pydantic analyse et valide en une seule étape. À l’inverse, model_dump() retourne un dict, tandis que model_dump_json() retourne une chaîne JSON.

Quand vous avez besoin de valider un type qui n’est pas un modèle, par exemple list[OrderItem], vous utilisez TypeAdapter :

from pydantic import TypeAdapter

items_adapter = TypeAdapter(list[OrderItem])

items = items_adapter.validate_python([
    {"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
    {"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50

Vous devez créer TypeAdapter une seule fois au niveau du module puis le réutiliser. L’initialisation a un coût de construction du schéma et ne doit pas être répétée à chaque requête.

9. Écrire des tests avec pytest

La validation est une logique métier et doit être testée. Supposons que les modèles ci-dessus se trouvent dans le fichier models.py :

from decimal import Decimal

import pytest
from pydantic import ValidationError

from models import Booking, Contact, OrderItem


def test_order_item_coerces_valid_input():
    item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")

    assert item.quantity == 3
    assert item.unit_price == Decimal("19.90")


@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
    with pytest.raises(ValidationError) as exc_info:
        OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")

    error = exc_info.value.errors()[0]
    assert error["loc"] == ("quantity",)
    assert error["type"] in {"greater_than", "less_than_equal"}


def test_contact_normalizes_phone():
    contact = Contact(name="an", phone="+84 912.345.678")

    assert contact.phone == "0912345678"
    assert contact.name == "An"


def test_booking_rejects_checkout_before_checkin():
    with pytest.raises(ValidationError, match="check_out doit être après check_in"):
        Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")

Exécuter les tests :

pytest -q
.....                                                    [100%]
5 passed

Notez que les tests font des assertions sur loc et type, ne comparant pas littéralement le msg. De cette façon, les tests ne se cassent pas quand Pydantic change la formulation du message dans une nouvelle version. parametrize vous aide à couvrir les deux limites 0 et 101 avec une seule fonction.

10. Erreurs courantes et quand ne pas utiliser

Confondre l’API de v1. De nombreuses vieilles documentations utilisent encore la syntaxe v1. Tableau de référence rapide :

v1                         v2
@validator                 @field_validator
@root_validator            @model_validator
class Config: ...          model_config = ConfigDict(...)
.dict() / .json()          .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw()  .model_validate() / .model_validate_json()

L’ancienne API fonctionne toujours mais émet DeprecationWarning. Vous devez migrer complètement vers la nouvelle API.

Surprises avec le lax mode. Le mode lax accepte "yes" comme True et 1.0 comme 1. Avec les données financières ou les ID, ce comportement peut être indésirable. Dans ce cas, activez strict.

Oublier return dans le validateur. Un field_validator sans return fera que le champ reçoit None. Un model_validator(mode="after") sans return self provoquera aussi une erreur.

Ignorer la validation sans le savoir. model_construct() crée une instance sans validation. Cette méthode est réservée aux données auxquelles vous faites entièrement confiance, par exemple relues depuis votre propre base de données.

Remarque : Ne pas utiliser Pydantic pour tous les objets internes. Dans les boucles à chaud traitant des millions d’objets déjà propres, le coût de la validation est superflu. Dans ce cas, dataclass ou NamedTuple conviendrait mieux. Placez Pydantic à la limite de votre système : API, fichiers de configuration, messages provenant d’autres systèmes.

11. Conclusion

Pydantic v2 transforme la validation des données d’entrée en déclaration plutôt qu’en instructions. Vous décrivez les types avec des annotations de type, ajoutez des contraintes avec Field et Annotated, écrivez des règles personnalisées avec field_validator et model_validator. Les comportements généraux comme l’interdiction des champs supplémentaires ou la désactivation de la conversion de type sont contrôlés via ConfigDict.

Trois points à retenir : utilisez model_validate_json pour les entrées JSON, activez extra="forbid" pour les données importantes, et testez la validation basée sur loc/type plutôt que le contenu du message. Utilisé correctement, à la limite du système, Pydantic aide le cœur de votre application à ne traiter que des données nettoyées et correctement typées.

pythonpydanticvalidationpytest