Pydantic v2 y msgspec: validación y serialización robusta

Cuando recibes datos del exterior — un body JSON de una API, un archivo de configuración, una respuesta de terceros — no puedes confiar en que la estructura sea correcta. Necesitas un contrato que diga “esto tiene que ser un entero, esto tiene que ser un email, y si no cuadra, quiero saber exactamente qué falló”. Eso es exactamente lo que hace Pydantic: define esquemas con anotaciones de tipo estándar de Python y los convierte en validadores ejecutables.

En Pydantic v2, cada clase que hereda de BaseModel no es solo un dataclass con azúcar — es un esquema compilado. Cuando instancias User(**datos), Pydantic ejecuta un validador escrito en Rust (via el paquete pydantic-core) que coerciona tipos cuando tiene sentido ("42"42 para un campo int) y lanza un ValidationError detallado cuando no hay forma de recuperarse. Esa reescritura en Rust es la razón por la que v2 es entre 5x y 50x más rápido que v1 en benchmarks reales, no marketing.

El momento donde Pydantic brilla es cualquier frontera de tu sistema: entradas de usuario en FastAPI, .env parseado con pydantic-settings, respuestas de APIs externas que “deberían” tener cierta forma. Si los datos vienen de fuera de tu proceso, Pydantic es el guardia de seguridad correcto.

Lo que se rompe si lo ignoras es predecible: un campo None donde esperabas un str sobrevive hasta producción, o peor, persiste en tu base de datos silenciosamente.

msgspec es una apuesta diferente: menos funcionalidades, máxima velocidad. Define Struct en lugar de BaseModel, y su encoder/decoder maneja JSON y MessagePack directamente en C, sin pasar por objetos Python intermedios. Para pipelines de alto throughput donde no necesitas lógica de validación compleja (validadores custom, aliasing sofisticado), msgspec gana por goleada.

from __future__ import annotations

import json
from datetime import date
from enum import Enum
from typing import Annotated

import msgspec
import msgspec.json as msjson
from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator


# ── Pydantic v2 ──────────────────────────────────────────────────────────────

class Role(str, Enum):
    admin = "admin"
    viewer = "viewer"
    editor = "editor"


class Address(BaseModel):
    street: str
    city: str
    zip_code: Annotated[str, Field(pattern=r"^\d{5}$")]


class User(BaseModel):
    name: Annotated[str, Field(min_length=1, max_length=100)]
    email: EmailStr
    age: Annotated[int, Field(ge=0, le=130)]
    role: Role = Role.viewer
    address: Address
    joined: date = Field(default_factory=date.today)

    # field_validator corre DESPUÉS de la coerción de tipo
    @field_validator("name")
    @classmethod
    def normalize_name(cls, v: str) -> str:
        return v.strip().title()


raw_payload = {
    "name": "  ana torres  ",
    "email": "ana@example.com",
    "age": "29",           # string → int: Pydantic coerciona sin quejarse
    "role": "editor",
    "address": {
        "street": "Calle Mayor 10",
        "city": "Madrid",
        "zip_code": "28001",
    },
}

user = User(**raw_payload)

print(user.name)                   # "Ana Torres"  ← normalizado por el validator
print(user.model_dump())           # dict Python nativo
print(user.model_dump_json())      # JSON bytes, más rápido que json.dumps(model_dump())

# Excluir campos o cambiar alias al serializar
print(user.model_dump(exclude={"joined"}, mode="json"))  # fechas como strings ISO

# ValidationError con contexto preciso
bad_payload = {"name": "", "email": "no-es-email", "age": 200, "address": {}}
try:
    User(**bad_payload)
except ValidationError as exc:
    # exc.errors() devuelve lista de dicts con loc, msg, type
    for error in exc.errors():
        print(f"  campo: {error['loc']} | error: {error['msg']}")


# ── msgspec ───────────────────────────────────────────────────────────────────

class AddressSpec(msgspec.Struct):
    street: str
    city: str
    zip_code: str


class UserSpec(msgspec.Struct):
    name: str
    email: str
    age: int
    role: str = "viewer"
    address: AddressSpec | None = None


# encode/decode trabajan directamente sobre bytes — sin dict intermedios
encoded: bytes = msjson.encode(
    UserSpec(name="Ana Torres", email="ana@example.com", age=29, address=AddressSpec("Calle Mayor 10", "Madrid", "28001"))
)
print(encoded)  # b'{"name":"Ana Torres",...}'

decoded: UserSpec = msjson.decode(encoded, type=UserSpec)
print(decoded.name)

# Para MessagePack (binario, más compacto que JSON)
packed: bytes = msgspec.msgpack.encode(decoded)
unpacked: UserSpec = msgspec.msgpack.decode(packed, type=UserSpec)
print(unpacked.age)

Qué está pasando aquí y por qué importa

El modelo User de Pydantic usa Annotated con Field para declarar constraints directamente en la anotación de tipo — esto es idiomático en v2 y permite que herramientas como mypy o pyright entiendan el esquema sin magia extra. EmailStr no es un tipo de Python estándar; Pydantic instala email-validator opcionalmente y convierte ese campo en un validador real, no una simple comprobación de que hay un @.

Fíjate en "age": "29" en el payload: Pydantic lo acepta porque su modo por defecto es lax para inputs externos — trata de coercionar si el valor es “sensiblemente convertible”. Puedes cambiar este comportamiento con model_config = ConfigDict(strict=True) si necesitas que "29" sea un error. Esa distinción entre modo lax y strict es una de las decisiones de diseño más relevantes de v2.

El field_validator con @classmethod corre después de que el tipo ya fue validado, por eso recibe un str limpio, no el valor bruto. Si necesitas acceder a otros campos durante la validación, usas model_validator(mode='after') — pero eso es territorio de lógica de negocio compleja.

model_dump_json() merece atención especial: serializa directamente a bytes JSON desde Rust, sin materializar el dict Python primero. En un endpoint que sirve miles de requests por segundo, esa diferencia se nota.

El contraste con msgspec es filosófico. Struct es inmutable por defecto, no tiene validadores custom ni aliasing, y no intenta coercionar tipos — si mandas "29" donde espera int, falla. A cambio, el encoder de msgspec en benchmarks independientes supera incluso a model_dump_json() de Pydantic v2. La elección correcta: si estás construyendo una API pública con reglas de negocio, Pydantic; si estás moviendo datos estructurados entre servicios internos a alta velocidad, msgspec.

Errores que debes conocer

Error: usar model.dict() o model.json() que son los métodos de Pydantic v1 — en v2 existen por compatibilidad pero están deprecados y no soportan las nuevas opciones.

# ❌ Wrong
data = user.dict()
raw_json = user.json()

# ✅ Right
data = user.model_dump()
raw_json = user.model_dump_json()

model_dump() y model_dump_json() son los métodos canónicos de v2, soportan mode, exclude, include, by_alias y round_trip.


Error: asumir que ValidationError es una excepción simple — intentar capturarla y solo leer str(exc) pierde la estructura detallada que Pydantic genera.

# ❌ Wrong
try:
    User(**bad_data)
except ValidationError as exc:
    raise HTTPException(status_code=422, detail=str(exc))

# ✅ Right
try:
    User(**bad_data)
except ValidationError as exc:
    raise HTTPException(status_code=422, detail=exc.errors(include_url=False))

exc.errors() devuelve una lista estructurada con loc (ruta al campo), msg (mensaje legible) y type (código de error), perfecta para respuestas de API que el cliente puede procesar programáticamente.


Error: declarar campos mutables como default sin default_factory — esto comparte la misma instancia entre todos los modelos, igual que el bug clásico de argumentos mutables en funciones Python.

# ❌ Wrong
class Config(BaseModel):
    tags: list[str] = []   # la misma lista para todas las instancias

# ✅ Right
class Config(BaseModel):
    tags: list[str] = Field(default_factory=list)

Field(default_factory=list) crea una lista nueva por instancia; Pydantic v2 en realidad lanza un warning si detectas este patrón, pero mejor no depender de eso.

186

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio