Pydantic v2: validar datos de entrada en Python
1. Descripción general
Los datos de peticiones HTTP, archivos JSON o message queues suelen tener tipos incorrectos, campos faltantes o valores sin sentido. Si validas con if/else disperso por todo el código, la lógica rápidamente se vuelve un desorden. Pydantic v2 te permite describir la “forma” de los datos usando type hints, y luego automáticamente parsea, convierte tipos y reporta errores detallados.
Este artículo recorre las herramientas principales de Pydantic v2: BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. La última parte trata cómo escribir pruebas con pytest. Después de leerlo, podrás construir tus propios modelos que validen la entrada de forma segura para tu aplicación.
2. Instalación
Este artículo usa Python 3.11+ y Pydantic 2.x:
pip install "pydantic>=2.7" pytest
Si necesitas el tipo EmailStr, instala el extra:
pip install "pydantic[email]"
3. Modelos básicos con BaseModel
3.1. Declaración y conversión de tipos
Un model es una clase que hereda de BaseModel. Cada atributo con type hint es un field:
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'>
Pasas id="42" como una cadena, pero Pydantic automáticamente la convierte a int. Este es el lax mode, el modo por defecto: Pydantic convierte tipos si la transformación es razonable. El field is_active tiene un valor por defecto, así que no es obligatorio proporcionarlo.
3.2. Manejo de ValidationError
Cuando los datos son incorrectos, Pydantic lanza ValidationError y reúne todos los errores a la vez, sin detenerse en el primero:
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
Cada error es un dict con loc (ubicación del field), type (código de error estable) y msg (mensaje para lectores). Al devolver errores al cliente, deberías confiar en loc y type. El contenido de msg puede cambiar entre versiones.
4. Restricciones de valores con Field y Annotated
Los type hints solo verifican tipos. Para restringir valores, usas 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')
Explicación:
patternrequiere queskucoincida con una regex, por ejemploABC-1234.Quantityes un tipo reutilizable gracias aAnnotated. Cualquier field declarado comoQuantitydebe estar en el rango(0, 100].unit_priceusaDecimalpara evitar errores de punto flotante. El valor debe ser positivo y tener como máximo 2 decimales.
Intenta pasar datos incorrectos:
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
Otras restricciones comúnmente usadas: min_length/max_length para strings y listas, ge/lt para números, multiple_of para múltiplos.
5. Validadores personalizados con field_validator
Cuando las restricciones son más complejas que regex o rangos de valores, escribes validadores personalizados:
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 no puede estar vacío")
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'
Hay dos modos que debes diferenciar:
mode="after"(por defecto), comoclean_name: se ejecuta después de que Pydantic ha verificado el tipo, así queves definitivamentestr.mode="before", comonormalize_phone: se ejecuta antes de la verificación de tipos ypattern.vpuede ser cualquier cosa, así que debes revisarisinstance. Aquí normalizamos+84 912 345 678a0912345678, luego se aplica elpattern.
El validador debe retornar un valor. El valor retornado es lo que se almacena en el field. Para reportar un error, lanzas ValueError o AssertionError. Pydantic lo envuelve en un error value_error con el mensaje "Value error, name no puede estar vacío".
Nota: El orden de los decoradores es muy importante.
@field_validatordebe estar encima de@classmethod. Si los inviertes, obtendrás un error confuso al definir la clase.
6. Validar múltiples fields a la vez con model_validator
Algunas reglas involucran múltiples fields, por ejemplo, la fecha de checkout debe ser posterior a la de check-in. Para esto necesitas 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 debe ser posterior a 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 debe ser posterior a check_in
Con mode="after", el validador recibe una instancia que ya es válida field por field. Accedes a self.check_in como un atributo normal y debes return self. El error tiene loc vacío () porque pertenece al modelo completo, no a un field específico. La cadena "2026-10-20" también se parsea automáticamente a date.
7. Configurar modelos con ConfigDict
model_config controla el comportamiento general del modelo. Esta es una configuración común para datos sensibles como pagos:
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
Significado de cada opción:
extra="forbid": rechaza fields desconocidos comocoupon. Por defecto es"ignore", es decir, Pydantic ignora silenciosamente fields adicionales. Esto puede ocultar errores de tipografía en los nombres de fields del cliente.strict=True: desactiva la conversión de tipos."1"ya no es aceptado paraint.str_strip_whitespace=True: automáticamente elimina espacios en blanco al inicio y final de cadenas.frozen=True: la instancia es inmutable. Asignarpayment.amount = 0lanzará un error.
Si solo quieres strict para algunos fields, usas Field(strict=True) o tipos como StrictInt, StrictStr.
Otra opción digna de mención es validate_assignment=True. Por defecto, Pydantic no valida cuando reasignas un atributo después de crear el objeto. Activa esta opción para mantener el modelo siempre válido.
8. Validar desde dict, JSON y tipos arbitrarios
En la práctica, los datos suelen llegar como dict o cadenas JSON. Pydantic v2 proporciona dos métodos de clase:
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 la entrada es una cadena JSON, debes usar model_validate_json en lugar de json.loads y luego model_validate. Esto es más rápido porque el núcleo en Rust de Pydantic parsea y valida en un solo paso. En la dirección opuesta, model_dump() retorna un dict, mientras que model_dump_json() retorna una cadena JSON.
Cuando necesitas validar un tipo que no es un modelo, por ejemplo list[OrderItem], usas 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
Deberías crear TypeAdapter una vez a nivel de módulo y reutilizarlo. La inicialización tiene un costo de construcción de schema, no debería repetirse en cada request.
9. Escribir pruebas con pytest
La validación es lógica de negocio, así que debe ser probada. Supongamos que los modelos anteriores están en el archivo 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 debe ser posterior a check_in"):
Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")
Ejecuta las pruebas:
pytest -q
..... [100%]
5 passed
Nota que las pruebas usan assert en loc y type, no comparan el msg literalmente. De esta forma, las pruebas no se rompen si Pydantic cambia el texto del mensaje en una versión nueva. parametrize te ayuda a cubrir ambos límites 0 y 101 con una sola función.
10. Errores comunes y cuándo no usar Pydantic
Usar la API de v1 por error. Muchos documentos antiguos todavía usan la sintaxis v1. Tabla de referencia rápida:
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()
La API antigua aún funciona pero emite DeprecationWarning. Deberías migrar completamente a la API nueva.
Sorpresas con lax mode. El lax mode acepta "yes" como True y 1.0 como 1. Con datos financieros o IDs, este comportamiento podría no ser deseado. En ese caso, activa strict.
Olvidar return en el validador. Un field_validator sin return hará que el field reciba None. Un model_validator(mode="after") sin return self también causa error.
Saltarse la validación sin darte cuenta. model_construct() crea una instancia sin pasar por validación. Este método es solo para datos en los que confías absolutamente, por ejemplo cuando lees desde tu propia base de datos.
Nota: No deberías usar Pydantic para todos los objetos internos. En bucles calientes que procesan millones de objetos limpios, el costo de validación es innecesario. En esos casos,
dataclassoNamedTupleson más apropiados. Coloca Pydantic en los límites de tu sistema: APIs, archivos de configuración, mensajes de otros sistemas.
11. Conclusión
Pydantic v2 transforma la validación de datos de entrada de imperativa a declarativa. Describes tipos con type hints, añades restricciones con Field y Annotated, escribes reglas personalizadas con field_validator y model_validator. Los comportamientos comunes como prohibir fields adicionales o desactivar conversión de tipos se controlan vía ConfigDict.
Tres puntos para recordar: usa model_validate_json para entrada JSON, activa extra="forbid" con datos críticos, y prueba la validación basándote en loc/type en lugar del contenido del mensaje. Aplicado correctamente, en los límites de tu sistema, Pydantic ayuda a que el núcleo de tu aplicación solo tenga que trabajar con datos limpios y de tipo correcto.