Ilustración técnica para: Validación de Datos Robusta en Python con Pydantic: Más Allá de los Diccionarios

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 como Optional[Tipo]. Si no, Pydantic lo trata como obligatorio y falla con missing, no con un valor None silencioso.
  • Confiar en la coerción de bool: Pydantic acepta "true", "1", 1 como verdadero. Si tu dominio necesita rechazar cualquier cosa que no sea un booleano literal, usa StrictBool en vez de bool.
  • Un alias que no se rellena: si defines Field(alias="productId") pero instancias con id_producto=101 en vez de productId=101, Pydantic v2 acepta ambos por defecto en la configuración estándar, pero si has personalizado model_config puede 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 siempre e.errors() (lista de diccionarios) para lógica y e.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 @validator de v1) para reglas que no se expresan como un tipo o un Field, 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 .env con la misma garantía de tipos correctos al arrancar, no en mitad de una petición.
  • Generación de JSON Schema: cualquier BaseModel puede 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ó.

Compartir X LinkedIn