Pydantic v2 en Python: BaseModel, validators, model_config y serialización

Pydantic es la librería de validación de datos más usada en el ecosistema Python. Pydantic v2, lanzada en 2023, reescribió el núcleo en Rust y triplicó la velocidad respecto a v1. La API cambió en aspectos importantes: model_dump() en lugar de dict(), ConfigDict en lugar de class Config, y nuevos decoradores de validación.

BaseModel: definir y validar modelos

from pydantic import BaseModel, ValidationError

class Usuario(BaseModel):
    nombre: str
    email: str
    edad: int
    activo: bool = True

# Validación automática al instanciar
u = Usuario(nombre="Ana", email="[email protected]", edad=30)
print(u)
# nombre='Ana' email='[email protected]' edad=30 activo=True

# Pydantic convierte tipos cuando puede
u2 = Usuario(nombre="Luis", email="[email protected]", edad="25")  # edad como string
print(u2.edad, type(u2.edad))  # 25 

# Error si los datos no son válidos
try:
    Usuario(nombre="Error", email="correo", edad="no-es-numero")
except ValidationError as e:
    print(e.json(indent=2))

Field() para restricciones y metadatos

from pydantic import BaseModel, Field

class Producto(BaseModel):
    nombre: str = Field(..., min_length=1, max_length=100, description="Nombre del producto")
    precio: float = Field(..., gt=0, description="Precio en euros")
    stock: int = Field(0, ge=0, description="Unidades disponibles")
    sku: str = Field(..., pattern=r"^[A-Z]{3}-d{4}$", description="Código SKU")
    etiquetas: list[str] = Field(default_factory=list)

p = Producto(nombre="Teclado", precio=49.99, sku="TEC-0001")
print(p.model_dump())
# {'nombre': 'Teclado', 'precio': 49.99, 'stock': 0, 'sku': 'TEC-0001', 'etiquetas': []}

@field_validator: validar campos individuales

from pydantic import BaseModel, field_validator

class Registro(BaseModel):
    username: str
    password: str
    email: str

    @field_validator("username")
    @classmethod
    def username_sin_espacios(cls, v: str) -> str:
        if " " in v:
            raise ValueError("El username no puede contener espacios")
        return v.lower()

    @field_validator("password")
    @classmethod
    def password_segura(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("La contraseña debe tener al menos 8 caracteres")
        if not any(c.isdigit() for c in v):
            raise ValueError("La contraseña debe contener al menos un número")
        return v

    @field_validator("email")
    @classmethod
    def email_valido(cls, v: str) -> str:
        if "@" not in v or "." not in v.split("@")[-1]:
            raise ValueError("Email inválido")
        return v.lower()

r = Registro(username="AnaGarcia", password="segura123", email="[email protected]")
print(r.username)  # anagarcia  (normalizado a minúsculas)
print(r.email)     # [email protected]

@model_validator: validar el modelo completo

from pydantic import BaseModel, model_validator

class Rango(BaseModel):
    minimo: float
    maximo: float

    @model_validator(mode="after")
    def validar_rango(self) -> "Rango":
        if self.minimo >= self.maximo:
            raise ValueError(f"minimo ({self.minimo}) debe ser menor que maximo ({self.maximo})")
        return self

class PasswordConfirm(BaseModel):
    password: str
    confirmar_password: str

    @model_validator(mode="after")
    def passwords_coinciden(self) -> "PasswordConfirm":
        if self.password != self.confirmar_password:
            raise ValueError("Las contraseñas no coinciden")
        return self

r = Rango(minimo=0.0, maximo=100.0)  # OK
# Rango(minimo=50.0, maximo=10.0)    # ValidationError

ConfigDict: configurar comportamiento del modelo

from pydantic import BaseModel, ConfigDict

class Config(BaseModel):
    model_config = ConfigDict(
        frozen=True,          # instancias inmutables
        str_strip_whitespace=True,  # trim automático en strings
        validate_default=True,      # validar también los valores por defecto
        extra="forbid",       # error si se pasan campos no declarados
    )

    host: str = "localhost"
    puerto: int = 5432

cfg = Config(host="  db.prod.com  ")
print(cfg.host)  # "db.prod.com"  (trim aplicado)
# cfg.host = "otro"  # TypeError (frozen)

model_dump() y serialización

from pydantic import BaseModel
from datetime import datetime

class Articulo(BaseModel):
    titulo: str
    contenido: str
    fecha: datetime
    publicado: bool = False

a = Articulo(
    titulo="Pydantic v2",
    contenido="Guía completa",
    fecha=datetime(2024, 3, 15, 10, 0, 0)
)

# model_dump() reemplaza a dict()
print(a.model_dump())
# {'titulo': 'Pydantic v2', 'contenido': '...', 'fecha': datetime(...), 'publicado': False}

# Serializar a JSON
print(a.model_dump_json(indent=2))

# Solo algunos campos
print(a.model_dump(include={"titulo", "publicado"}))

# Excluir campos
print(a.model_dump(exclude={"contenido"}))

# Con alias para la salida
print(a.model_dump(by_alias=True))

Discriminated unions con FastAPI

from typing import Annotated, Literal
from pydantic import BaseModel, Field

class Perro(BaseModel):
    tipo: Literal["perro"]
    nombre: str
    raza: str

class Gato(BaseModel):
    tipo: Literal["gato"]
    nombre: str
    interior: bool

Animal = Annotated[
    Perro | Gato,
    Field(discriminator="tipo")
]

class Registro(BaseModel):
    mascota: Animal

r1 = Registro(mascota={"tipo": "perro", "nombre": "Rex", "raza": "Labrador"})
r2 = Registro(mascota={"tipo": "gato", "nombre": "Misi", "interior": True})
print(type(r1.mascota))  # 
print(type(r2.mascota))  # 

Las diferencias clave entre v1 y v2: .dict() ? .model_dump(); class Config ? model_config = ConfigDict(...); @validator ? @field_validator con @classmethod; @root_validator ? @model_validator. La migración de v1 a v2 tiene una guía oficial y la herramienta bump-pydantic automatiza gran parte de los cambios.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP