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 :
patternobligeskuà correspondre à la regex, par exempleABC-1234.Quantityest un type réutilisable grâce àAnnotated. Tout champ déclaré en tant queQuantitydoit se situer dans la plage(0, 100].unit_priceutiliseDecimalpour é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), commeclean_name: s’exécute après que Pydantic a vérifié le type, doncvest sûrement unestr.mode="before", commenormalize_phone: s’exécute avant la vérification du type etpattern.vpeut être n’importe quoi, donc vous devez vérifierisinstance. Ici, nous normalisons+84 912 345 678en0912345678, puispatternest 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_validatordoit ê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 commecoupon. 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é pourint.str_strip_whitespace=True: supprime automatiquement les espaces à chaque extrémité de la chaîne.frozen=True: l’instance est immuable. L’assignationpayment.amount = 0levera 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,
dataclassouNamedTupleconviendrait 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.