Validación de datos con Pydantic en Python: modelos, validators y parsing
Pydantic es la biblioteca de validación de datos más popular en el ecosistema Python. Convierte y valida datos automáticamente, genera esquemas JSON y es la base de FastAPI, SQLModel y muchas herramientas modernas.
1. Instalación y primer modelo
pip install pydantic # Pydantic v2
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class Usuario(BaseModel):
id: int
nombre: str
email: str
edad: int
activo: bool = True
creado_en: datetime = Field(default_factory=datetime.now)
bio: Optional[str] = None
# Creación — Pydantic valida y convierte los tipos automáticamente
usuario = Usuario(
id=1,
nombre="Ana García",
email="ana@ejemplo.com",
edad="28", # Cadena → int automáticamente
)
print(usuario.nombre) # Ana García
print(type(usuario.edad)) # <class 'int'>
print(usuario.activo) # True (valor por defecto)
# Acceso como dict
print(usuario.model_dump())
# {'id': 1, 'nombre': 'Ana García', 'email': 'ana@ejemplo.com', 'edad': 28, ...}
# Serialización JSON
print(usuario.model_dump_json(indent=2))
2. Field: restricciones detalladas
from pydantic import BaseModel, Field
from typing import Annotated
class Producto(BaseModel):
id: int = Field(gt=0) # > 0
nombre: str = Field(min_length=1, max_length=200)
precio: float = Field(gt=0, le=99999.99)
stock: int = Field(ge=0, default=0) # >= 0
descuento: float = Field(ge=0.0, le=1.0, default=0.0) # 0-100%
sku: str = Field(pattern=r"^[A-Z]{2}\d{6}$") # Regex: AB123456
descripcion: str = Field(default="", max_length=5000)
# Field con alias (útil para APIs con nombres en camelCase)
precio_costo: float = Field(alias="costPrice", gt=0)
class Config:
populate_by_name = True # Aceptar nombre original y alias
# Con Annotated (Pydantic v2 recomendado)
PrecioPositivo = Annotated[float, Field(gt=0, le=99999.99)]
NombreCorto = Annotated[str, Field(min_length=1, max_length=100)]
class ProductoV2(BaseModel):
nombre: NombreCorto
precio: PrecioPositivo
3. Validators personalizados
from pydantic import BaseModel, field_validator, model_validator
from typing import Optional
class Pedido(BaseModel):
cliente: str
total: float
descuento: float = 0.0
total_final: Optional[float] = None
@field_validator("cliente")
@classmethod
def nombre_no_vacio(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("El nombre del cliente no puede estar vacío")
return v.title() # Capitalizar
@field_validator("total", "descuento")
@classmethod
def valores_positivos(cls, v: float, info) -> float:
if v < 0:
raise ValueError(f"{info.field_name} no puede ser negativo")
return round(v, 2)
@model_validator(mode="after")
def calcular_total_final(self) -> "Pedido":
self.total_final = round(self.total * (1 - self.descuento), 2)
return self
pedido = Pedido(cliente=" ana garcía ", total=100.0, descuento=0.15)
print(pedido.cliente) # Ana García
print(pedido.total_final) # 85.0
4. Modelos anidados
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class Direccion(BaseModel):
calle: str
ciudad: str
codigo_postal: str
pais: str = "España"
class LineaPedido(BaseModel):
producto_id: int
nombre: str
cantidad: int
precio_unit: float
@property
def subtotal(self) -> float:
return self.cantidad * self.precio_unit
class PedidoCompleto(BaseModel):
id: int
cliente: str
direccion: Direccion
lineas: List[LineaPedido]
creado_en: datetime = None
notas: Optional[str] = None
@property
def total(self) -> float:
return sum(l.subtotal for l in self.lineas)
# Pydantic acepta dicts anidados y los convierte automáticamente
pedido = PedidoCompleto(
id=1,
cliente="Ana",
direccion={
"calle": "Calle Mayor 1",
"ciudad": "Madrid",
"codigo_postal": "28013",
},
lineas=[
{"producto_id": 10, "nombre": "Teclado", "cantidad": 2, "precio_unit": 49.99},
{"producto_id": 20, "nombre": "Ratón", "cantidad": 1, "precio_unit": 29.99},
],
)
print(pedido.total) # 129.97
print(pedido.direccion.ciudad) # Madrid
5. Serialización y deserialización
import json
from pydantic import BaseModel
from datetime import date
class Empleado(BaseModel):
id: int
nombre: str
salario: float
fecha_alta: date
emp = Empleado(id=1, nombre="Carlos", salario=45000.0, fecha_alta=date(2023, 1, 15))
# model_dump: dict Python
d = emp.model_dump()
print(d) # {'id': 1, 'nombre': 'Carlos', 'salario': 45000.0, 'fecha_alta': datetime.date(2023, 1, 15)}
# model_dump_json: JSON string
json_str = emp.model_dump_json()
print(json_str) # {"id":1,"nombre":"Carlos","salario":45000.0,"fecha_alta":"2023-01-15"}
# model_validate: dict → modelo
datos = {"id": 2, "nombre": "Lucía", "salario": "38000", "fecha_alta": "2024-06-01"}
emp2 = Empleado.model_validate(datos)
print(emp2.salario) # 38000.0 (str → float)
print(emp2.fecha_alta) # 2024-06-01 (str → date)
# model_validate_json: JSON → modelo
emp3 = Empleado.model_validate_json(json_str)
print(emp3.nombre) # Carlos
# Esquema JSON (para APIs, documentación)
print(json.dumps(Empleado.model_json_schema(), indent=2))
6. Configuración avanzada con model_config
from pydantic import BaseModel
from pydantic.config import ConfigDict
class ConfiguracionApp(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # Strip whitespace en strings
str_to_lower=False, # No convertir a minúsculas
frozen=True, # Inmutable (como frozen dataclass)
extra="forbid", # Rechazar campos no declarados
validate_assignment=True, # Validar al asignar atributos
populate_by_name=True, # Aceptar nombre original y alias
)
host: str
puerto: int
debug: bool = False
# extra="forbid" rechaza campos desconocidos
try:
ConfiguracionApp(host="localhost", puerto=8000, campo_extra="x")
except Exception as e:
print(e) # Extra inputs are not permitted
# frozen=True: inmutable
config = ConfiguracionApp(host="localhost", puerto=8000)
try:
config.host = "otro" # ValidationError: frozen
except Exception as e:
print(e)
7. Discriminated unions: polimorfismo
from pydantic import BaseModel
from typing import Annotated, Union, Literal
class Circulo(BaseModel):
tipo: Literal["circulo"] = "circulo"
radio: float
def area(self) -> float:
import math
return math.pi * self.radio ** 2
class Rectangulo(BaseModel):
tipo: Literal["rectangulo"] = "rectangulo"
ancho: float
alto: float
def area(self) -> float:
return self.ancho * self.alto
class Triangulo(BaseModel):
tipo: Literal["triangulo"] = "triangulo"
base: float
altura: float
def area(self) -> float:
return 0.5 * self.base * self.altura
Figura = Annotated[
Union[Circulo, Rectangulo, Triangulo],
# Pydantic elige el modelo correcto según el campo 'tipo'
]
class Dibujo(BaseModel):
figuras: list[Figura]
dibujo = Dibujo(figuras=[
{"tipo": "circulo", "radio": 5},
{"tipo": "rectangulo", "ancho": 4, "alto": 3},
])
for fig in dibujo.figuras:
print(f"{fig.tipo}: área = {fig.area():.2f}")
8. Validar datos de una API externa
import requests
from pydantic import BaseModel, field_validator
from typing import Optional, List
class Repositorio(BaseModel):
id: int
name: str
full_name: str
description: Optional[str] = None
stargazers_count: int
language: Optional[str] = None
html_url: str
@field_validator("name")
@classmethod
def nombre_valido(cls, v: str) -> str:
if not v or len(v) > 100:
raise ValueError("Nombre de repositorio inválido")
return v
def obtener_repos_usuario(usuario: str) -> List[Repositorio]:
r = requests.get(f"https://api.github.com/users/{usuario}/repos", timeout=10)
r.raise_for_status()
return [Repositorio.model_validate(repo) for repo in r.json()]
repos = obtener_repos_usuario("torvalds")
for repo in sorted(repos, key=lambda r: r.stargazers_count, reverse=True)[:3]:
print(f"{repo.name}: {repo.stargazers_count} estrellas")
9. Buenas prácticas
- Usa
Field()siempre que necesites restricciones, alias o valores por defecto complejos. - Prefiere
model_validate()sobre el constructor cuando los datos vienen de una fuente externa. extra="forbid"en APIs para rechazar campos inesperados y detectar bugs temprano.frozen=Truepara modelos de configuración o claves de caché.- Reutiliza tipos con
Annotatedpara evitar repetir las mismas restricciones en varios modelos. - Versión: Pydantic v2 (instalado por defecto desde 2023) usa
model_dump()ymodel_validate()en lugar de.dict()y.parse_obj()de v1.
Resumen de métodos principales (Pydantic v2)
| Método | Descripción |
|---|---|
Model(**datos) |
Crear instancia con validación |
model_validate(dict) |
dict → modelo |
model_validate_json(str) |
JSON string → modelo |
model_dump() |
modelo → dict |
model_dump_json() |
modelo → JSON string |
model_json_schema() |
Generar esquema JSON |
model_copy(update={}) |
Copia con cambios |
Conversiones relacionadas
Conversiones frecuentes del catálogo: