Pydantic en producción: del pedido roto al modelo que se valida solo
Un pedido de e-commerce llega así desde una API de terceros: un producto con precio negativo, otro con una URL de seguimiento que no es una URL, un campo obligatorio ausente. Tu código de validación manual, hecho de isinstance() anidados y try/except por campo, detecta algunos de estos casos y deja pasar otros silenciosamente. El síntoma no es un TypeError ruidoso: es un pedido inválido que llega a tu lógica de negocio y explota tres funciones más adelante, lejos de donde realmente falló.
La causa: no hay un contrato explícito de los datos
El problema de fondo no es que falte una validación puntual, es que no existe una definición única de "cómo tienen que ser estos datos" que toda la aplicación pueda consultar. Cada función que toca el pedido reimplementa su propia versión de esa validación, con sus propios huecos. Pydantic, en su versión estable vigente 2.13.4 (publicada en mayo de 2026 según su ficha en PyPI), resuelve esto invirtiendo el problema: en vez de escribir lógica de validación imperativa dispersa por el código, declaras la forma de los datos una sola vez con BaseModel y las anotaciones de tipo de Python, y esa declaración se convierte en la única fuente de verdad.
from typing import Optional
from pydantic import BaseModel, Field, HttpUrl
class Producto(BaseModel):
id_producto: int = Field(alias="productId")
nombre: str
precio: float = Field(gt=0)
stock: int = Field(ge=0)
class Pedido(BaseModel):
id_pedido: str = Field(alias="orderId")
cliente: str
productos: list[Producto]
url_seguimiento: Optional[HttpUrl] = None
La restricción gt=0 en precio y ge=0 en stock no son comentarios ni validación aparte: son parte de la declaración del tipo. Un precio negativo ya no es un bug que descubres en producción, es un dato que nunca llega a construir el objeto Producto.
Cómo confirmarlo: instanciar el modelo con datos reales
Cuando los datos son correctos, Pydantic no solo valida: también convierte tipos cuando es razonable hacerlo. Un "123" de la API se convierte en el entero 123 sin que tengas que escribir ese int(x) en ningún sitio.
datos = {"productId": 101, "nombre": "Laptop Pro", "precio": 1200.50, "stock": 15}
producto = Producto(**datos)
print(producto)
# id_producto=101 nombre='Laptop Pro' precio=1200.5 stock=15
Cuando los datos rompen el contrato, Pydantic no lanza una excepción por campo: agrupa todos los fallos en un único ValidationError, con el tipo de error, la ruta del campo y el valor recibido para cada uno.
from pydantic import ValidationError
try:
Producto(productId=102, nombre="Mouse", precio=-25.0, stock=30)
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"], err["msg"])
# ('precio',) 'greater_than' Input should be greater than 0
El método .errors() devuelve una lista de diccionarios pensada para tratarse programáticamente por el campo type (greater_than, missing, int_parsing...), no para parsear el texto de msg. Para JSON entrante directo de una API, la forma recomendada en Pydantic v2 es model_validate_json, que parsea y valida en un solo paso sin que tengas que llamar primero a json.loads:
pedido = Pedido.model_validate_json(raw_json_body)
Qué hacer: el procesador de pedidos completo
Con el contrato ya declarado, el flujo de producción es separar lo válido de lo inválido sin abortar el lote completo por un pedido malo:
from pydantic import BaseModel, Field, HttpUrl, ValidationError
from typing import Optional
class Producto(BaseModel):
id_producto: int = Field(alias="productId")
nombre: str
precio: float = Field(gt=0)
stock: int = Field(ge=0)
class Pedido(BaseModel):
id_pedido: str = Field(alias="orderId")
cliente: str
productos: list[Producto]
url_seguimiento: Optional[HttpUrl] = None
api_response = [
{"orderId": "ORD-001", "cliente": "Cliente A", "productos": [
{"productId": 101, "nombre": "Laptop Pro", "precio": 1200.50, "stock": 15},
{"productId": 102, "nombre": "Mouse Inalámbrico", "precio": -25.00, "stock": 30},
]},
{"orderId": "ORD-002", "cliente": "Cliente B", "productos": [
{"productId": 201, "nombre": "Teclado Mecánico", "precio": 95.75, "stock": 10},
], "url_seguimiento": "esto-no-es-una-url"},
{"orderId": "ORD-003", "cliente": "Cliente C", "productos": [
{"productId": 301, "nombre": "Monitor 4K", "precio": 450.00, "stock": 5},
], "url_seguimiento": "https://seguimiento.ejemplo.com/track/ORD-003"},
]
pedidos_validos, pedidos_rechazados = [], []
for idx, datos in enumerate(api_response):
try:
pedidos_validos.append(Pedido(**datos))
except ValidationError as e:
pedidos_rechazados.append({"pedido": idx + 1, "errores": e.errors()})
print(f"Válidos: {len(pedidos_validos)} / Rechazados: {len(pedidos_rechazados)}")
for r in pedidos_rechazados:
print(r["pedido"], [e["type"] for e in r["errores"]])
El pedido ORD-001 se rechaza completo porque contiene un producto con precio negativo (así debe ser: no quieres procesar parcialmente un pedido con un ítem corrupto). ORD-002 se rechaza por la URL de seguimiento inválida. Solo ORD-003 pasa. Este es el patrón real que usarías antes de insertar en base de datos, al leer de una cola como Kafka o RabbitMQ, o en cualquier endpoint que reciba JSON de un tercero.
Si necesitas un tipo de email más estricto que str, Pydantic lo ofrece como extra separado, no como parte del paquete base:
pip install pydantic[email]
from pydantic import EmailStr
class Usuario(BaseModel):
email: EmailStr
Errores que vas a cometer (y cómo depurarlos)
- Olvidar
Optional: un campo que puede faltar debe declararse comoOptional[Tipo]. Si no, Pydantic lo trata como obligatorio y falla conmissing, no con un valorNonesilencioso. - Confiar en la coerción de
bool: Pydantic acepta"true","1",1como verdadero. Si tu dominio necesita rechazar cualquier cosa que no sea un booleano literal, usaStrictBoolen vez debool. - Un alias que no se rellena: si defines
Field(alias="productId")pero instancias conid_producto=101en vez deproductId=101, Pydantic v2 acepta ambos por defecto en la configuración estándar, pero si has personalizadomodel_configpuede que solo acepte uno. - Depurar imprimiendo la excepción directa:
print(e)da una versión legible para humanos pero pobre para programar sobre ella. Usa siempree.errors()(lista de diccionarios) para lógica ye.json()solo para logging.
Próximos pasos una vez el modelo base funciona
Dominar BaseModel con Field y tipos con restricciones cubre la mayoría de los casos de validación de entrada. Los siguientes pasos naturales, en orden de cuánto retorno dan por el esfuerzo de aprenderlos:
@field_validator: el decorador vigente en Pydantic v2 (sustituye al antiguo@validatorde v1) para reglas que no se expresan como un tipo o unField, como que un NIF cumpla un formato concreto o que dos campos sean consistentes entre sí.pydantic-settings: paquete separado que aplica el mismo sistema de validación a la configuración de la aplicación, leyendo variables de entorno y archivos.envcon la misma garantía de tipos correctos al arrancar, no en mitad de una petición.- Generación de JSON Schema: cualquier
BaseModelpuede exportar su esquema como JSON Schema estándar, útil para documentar una API o para interoperar con otros sistemas sin reescribir el contrato a mano. - FastAPI: usa Pydantic de forma nativa para validar cuerpos de petición, parámetros de query y serializar respuestas. Si ya piensas en tus datos como modelos Pydantic, el salto a construir una API con ellos es casi directo.
El retorno de invertir tiempo en esto no es abstracto: cada validación que hoy escribes a mano es una superficie donde un caso borde se te va a escapar. Declarar el contrato una vez y dejar que Pydantic lo haga cumplir en cada punto de entrada es, en la práctica, la diferencia entre depurar un ValidationError con mensaje claro en el momento en que entra el dato malo, o depurar un AttributeError tres capas más abajo sin ninguna pista de dónde se originó.