Modo lectura
Python
Lectura continua de la biblioteca. Cada recurso funciona como un capítulo dentro del libro.
Capítulo 1 de 1
Pydantic
Validación en tiempo de ejecución con Pydantic v2
Guía de estudio para Pydantic v2.12.5
| Nivel | Intermedio |
|---|---|
| Objetivo | Entender por qué validar en tiempo de ejecución y cómo hacerlo bien con Pydantic v2 |
| Público | Data scientists y desarrolladores Python que trabajan con datos externos |
| Fecha | Abril 2026 |
---
1. La idea central: tipado estático + validación en tiempo de ejecución
El tipado estático y la validación en runtime no compiten entre sí: se complementan. El type checker ayuda a detectar errores mientras escribes código; Pydantic te protege cuando los datos entran a tu sistema desde fuera.
Ese "desde fuera" incluye archivos YAML, respuestas HTTP, variables de entorno, bases de datos y formularios de usuario. En todos esos puntos pueden aparecer errores que un analizador estático no ve, porque el problema no está en tu código, sino en los datos que llegan.
Idea clave: Tu programa puede estar perfectamente anotado y, aun así, romperse si recibe datos inválidos. Por eso conviene validar en el borde del sistema: justo donde los datos entran.
El objetivo de Pydantic no es solo "revisar" datos, sino convertirlos en estructuras bien definidas y confiables. A partir de ese momento, el resto del programa puede trabajar con objetos mucho más seguros y expresivos.
---
2. Caso de estudio: un restaurante configurable con YAML
Vamos a modelar la configuración de un restaurante cargada desde un archivo YAML. Es un ejemplo clásico de datos externos que necesitan validación.
Reglas de negocio
| Campo | Reglas |
|---|---|
name | Texto obligatorio, menos de 32 caracteres, solo ASCII, números, comillas, apóstrofe y espacios |
owner | Texto obligatorio no vacío |
address | Texto obligatorio no vacío |
employees | Lista con al menos 2 empleados; debe existir al menos un Chef y un Server |
payment_details | Cada empleado debe tener exactamente una forma de pago: dirección postal o datos bancarios |
dishes | Lista con al menos 3 platos; cada nombre debe ser único |
number_of_seats | Entero positivo |
to_go / delivery | Booleanos |
YAML de ejemplo
name: "Viafore's"
owner: Pat Viafore
address: 123 Fake St. Fakington, FA 01234
employees:
- name: Pat Viafore
position: Chef
payment_details:
bank_details:
routing_number: "123456789"
account_number: "123456789012"
- name: Made-up McGee
position: Server
payment_details:
bank_details:
routing_number: "123456789"
account_number: "123456789012"
- name: Fabricated Frank
position: Sous Chef
payment_details:
bank_details:
routing_number: "123456789"
account_number: "123456789012"
- name: Illusory Ilsa
position: Host
payment_details:
mailing_address: 99 Example Ave. Fakington, FA 01234
dishes:
- name: Rigatoni Roja
price_in_cents: 1295
description: Rigatoni with sausage in tomato, garlic, and basil sauce
- name: Bolognese
price_in_cents: 1495
description: Spaghetti with slow-cooked tomato and beef sauce
- name: Caprese Salad
price_in_cents: 795
description: Tomato, buffalo mozzarella, and basil
picture: caprese.png
number_of_seats: 12
to_go: true
delivery: falseAntes de escribir código: Conviene refinar los requisitos. "¿Texto no vacío?" no es lo mismo que "¿texto bien formado?". Un buen modelo empieza con buenas reglas de negocio.
---
3. Por qué un dict o un TypedDict no basta
Si lees YAML con PyYAML, normalmente obtienes un diccionario de Python:
import yaml
with open("restaurant.yaml", "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
print(type(data)) # dictUn TypedDict mejora la legibilidad y ayuda al type checker, pero no valida automáticamente los datos que vienen del archivo. El analizador estático no puede inspeccionar el contenido real de un YAML cargado en runtime.
from typing import Literal, TypedDict
Position = Literal["Chef", "Sous Chef", "Host", "Server", "Delivery Driver"]
class EmployeeTD(TypedDict):
name: str
position: Position
class RestaurantTD(TypedDict):
name: str
employees: list[EmployeeTD]Conclusión:
TypedDictes excelente para describir la forma esperada. Pydantic es excelente para construir objetos válidos a partir de datos reales.
---
4. Las herramientas de Pydantic v2
En Pydantic v2, el camino principal para modelar datos de entrada es BaseModel. Sigues trabajando con type hints normales de Python, pero la construcción del objeto incluye validación automática.
| Herramienta | Para qué sirve | Cuándo usarla |
|---|---|---|
BaseModel | Modelos de datos estructurados | Tu opción principal para payloads, config y datos externos |
Field | Metadatos y restricciones | Longitudes, mínimos, máximos, strict, descripción, alias |
Annotated | Adjuntar restricciones a un tipo | Patrón moderno recomendado en v2 |
StringConstraints | Restricciones específicas para str | Alternativa moderna a constr(...) |
field_validator | Validar un campo concreto | Reglas sobre una lista, string o número ya parseado |
model_validator | Validar relaciones entre campos | Reglas que dependen del modelo completo |
TypeAdapter | Validar tipos arbitrarios sin crear un modelo | Muy útil para list[Restaurant], dict[str, int], etc. |
---
5. Modelado paso a paso
5.1. Tipos reutilizables con Annotated
Una buena técnica es crear alias de tipos con Annotated. Eso reduce repetición y vuelve el modelo más legible.
from typing import Annotated, Literal
from pydantic import Field, PositiveInt, StringConstraints
NonEmptyStr = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1),
]
RestaurantName = Annotated[
str,
StringConstraints(
strip_whitespace=True,
min_length=1,
max_length=31, # "menos de 32"
pattern=r"^[A-Za-z0-9\'\" ]+$",
),
]
DishName = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=16),
]
DishDescription = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=80),
]
DigitsOnly = Annotated[str, StringConstraints(pattern=r"^\d+$")]
RoutingNumber = Annotated[str, StringConstraints(pattern=r"^\d{9}$")]
Position = Literal["Chef", "Sous Chef", "Host", "Server", "Delivery Driver"]El nombre del restaurante acepta apóstrofe para que
"Viafore's"sea válido. La restricción de longitud queda visible en el propio tipo.
5.2. Submodelos: pago, platos y empleados
from pydantic import BaseModel, ConfigDict, model_validator
class BankDetails(BaseModel):
model_config = ConfigDict(extra="forbid")
routing_number: RoutingNumber
account_number: DigitsOnly
class PaymentDetails(BaseModel):
model_config = ConfigDict(extra="forbid")
mailing_address: NonEmptyStr | None = None
bank_details: BankDetails | None = None
@model_validator(mode="after")
def exactly_one_payment_method(self):
if (self.mailing_address is None) == (self.bank_details is None):
raise ValueError(
"Debe existir exactamente una forma de pago: "
"mailing_address o bank_details"
)
return self
class Dish(BaseModel):
model_config = ConfigDict(extra="forbid")
name: DishName
price_in_cents: PositiveInt
description: DishDescription
picture: str | None = None
class Employee(BaseModel):
model_config = ConfigDict(extra="forbid")
name: NonEmptyStr
position: Position
payment_details: PaymentDetails5.3. Modelo principal
from pydantic import field_validator
class Restaurant(BaseModel):
model_config = ConfigDict(
extra="forbid",
validate_assignment=True,
)
name: RestaurantName
owner: NonEmptyStr
address: NonEmptyStr
employees: Annotated[list[Employee], Field(min_length=2)]
dishes: Annotated[list[Dish], Field(min_length=3)]
number_of_seats: PositiveInt
to_go: bool
delivery: bool
@field_validator("dishes")
@classmethod
def unique_dish_names(cls, dishes: list[Dish]) -> list[Dish]:
normalized = [dish.name.casefold() for dish in dishes]
if len(normalized) != len(set(normalized)):
raise ValueError("Cada plato debe tener un nombre único")
return dishes
@model_validator(mode="after")
def check_staff_mix(self):
positions = {employee.position for employee in self.employees}
if "Chef" not in positions or "Server" not in positions:
raise ValueError(
"El restaurante debe tener al menos un Chef y un Server"
)
return selfPor qué este diseño funciona bien:
- Las restricciones simples viven en el tipo o en
Field(...). - Las reglas de negocio entre campos viven en
model_validator. - Los errores por claves inesperadas se detectan con
extra="forbid". - Los cambios posteriores a la creación también se validan con
validate_assignment=True.
---
6. Cargar el YAML y construir un objeto seguro
El flujo moderno en v2: cargar datos crudos → validar con model_validate() → serializar con model_dump().
import yaml
with open("restaurant.yaml", "r", encoding="utf-8") as f:
raw_data = yaml.safe_load(f)
restaurant = Restaurant.model_validate(raw_data)
print(type(restaurant)) # <class 'Restaurant'>
print(restaurant.name) # Viafore's
print(restaurant.model_dump())Una vez que model_validate() termina bien, el resto de tu programa trabaja con un Restaurant bien formado, no con un dict ambiguo.
6.1. Qué ocurre cuando algo falla
bad_data = {
"name": "Viafore's!", # carácter no permitido
"owner": "Pat Viafore",
"address": "123 Fake St.",
"employees": [], # menos de 2
"dishes": [], # menos de 3
"number_of_seats": -3, # debe ser positivo
"to_go": True,
"delivery": False,
}
Restaurant.model_validate(bad_data)
# ValidationError con todos los campos que fallaronPydantic genera un ValidationError con información precisa sobre qué campo falló y por qué. Eso es mucho mejor que descubrir el problema mucho después, cuando otra parte del sistema intenta usar esos datos.
6.2. Leer errores de forma programática
from pydantic import ValidationError
try:
Restaurant.model_validate(bad_data)
except ValidationError as exc:
print(exc) # formato legible para humanos
print(exc.errors()) # lista de dicts, útil para logging o APIs---
7. Parsing y validación estricta
Pydantic, por defecto, intenta convertir datos cuando esa conversión es razonable:
from pydantic import BaseModel
class Demo(BaseModel):
seats: int
print(Demo.model_validate({"seats": "12"})) # seats=12Cuando quieras ser más estricto, tienes dos opciones:
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
# Modo estricto a nivel de modelo completo
class StrictRestaurant(BaseModel):
model_config = ConfigDict(strict=True)
number_of_seats: int
delivery: bool
# Modo estricto solo en campos específicos
class SemiStrictRestaurant(BaseModel):
number_of_seats: Annotated[int, Field(strict=True)]
delivery: boolRegla práctica: Modo laxo en bordes donde los datos vienen "sucios" pero son previsibles. Modo estricto en campos donde una coerción silenciosa sería peligrosa o confusa.
---
8. TypeAdapter: valida tipos sin crear un modelo nuevo
TypeAdapter sirve para validar listas, uniones, diccionarios o cualquier otro tipo anotado sin necesidad de envolverlo en una clase BaseModel.
from pydantic import TypeAdapter
adapter = TypeAdapter(list[Restaurant])
restaurants = adapter.validate_python(raw_data_list)Este patrón es especialmente útil cuando tu archivo contiene varias entradas o cuando quieres validar piezas sueltas de configuración.
---
9. Referencia rápida de migración v1 → v2
| Antes (v1) | Ahora (v2) | Nota |
|---|---|---|
@validator | @field_validator | El decorador antiguo sigue existiendo pero está deprecado |
@root_validator | @model_validator | La validación del modelo es ahora más explícita |
parse_obj() | model_validate() | Nuevo nombre canónico para validar datos Python |
dict() | model_dump() | Nuevo nombre canónico para serializar a dict |
json() | model_dump_json() | Salida JSON moderna |
parse_raw() | model_validate_json() | O cargas el dato tú y luego usas model_validate() |
constr(regex=...) | Annotated[str, StringConstraints(pattern=...)] | Patrón moderno, más amigable con análisis estático |
conlist(T, min_items=3) | Annotated[list[T], Field(min_length=3)] | Estilo actual preferido |
Para migraciones grandes existe la herramienta bump-pydantic que automatiza parte del trabajo.
---
10. Dataclasses de Pydantic
Son una buena opción si quieres una experiencia parecida a las dataclasses de la biblioteca estándar, pero con validación:
from pydantic import ConfigDict, field_validator
from pydantic.dataclasses import dataclass
@dataclass(config=ConfigDict(validate_assignment=True))
class DishDC:
name: str
price_in_cents: int
@field_validator("price_in_cents")
@classmethod
def positive_price(cls, value: int) -> int:
if value <= 0:
raise ValueError("El precio debe ser positivo")
return valuePara aprender la API moderna de Pydantic v2, empieza con
BaseModel. Si tu proyecto ya usa dataclasses y te aportan claridad, puedes seguir con ellas.
---
11. Resumen
- Usa
BaseModelpara la mayoría de los datos externos. - Usa
Annotatedpara adjuntar restricciones de forma legible. - Usa
StringConstraintspara strings yField(...)para listas, números y metadatos. - Usa
field_validatorpara una regla local; usamodel_validatorpara reglas que dependen de varios campos. - Usa
extra="forbid"si quieres detectar claves inesperadas. - Usa
validate_assignment=Truesi quieres revalidar cambios después de crear el objeto. - Usa
TypeAdaptercuando no necesites un modelo completo. - Piensa siempre dónde quieres coerción y dónde prefieres modo estricto.
---
12. Ejercicios
- Añade una regla: si
delivery=True, debe existir al menos un empleado con posición"Delivery Driver". - Haz que
picturesolo acepte extensiones.png,.jpgo.jpeg. - Escribe una versión que valide
list[Restaurant]usandoTypeAdapter. - Convierte el ejemplo principal a dataclasses de Pydantic y compáralo con
BaseModel. - Activa
strict=Trueen todo el modelo y analiza qué entradas que antes pasaban ahora fallan. - Añade tests unitarios para nombres duplicados de platos y para métodos de pago inválidos.
---
13. Código completo de referencia
from typing import Annotated, Literal
import yaml
from pydantic import (
BaseModel,
ConfigDict,
Field,
PositiveInt,
StringConstraints,
TypeAdapter,
ValidationError,
field_validator,
model_validator,
)
# ── Tipos reutilizables ────────────────────────────────────────────────────────
NonEmptyStr = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1),
]
RestaurantName = Annotated[
str,
StringConstraints(
strip_whitespace=True,
min_length=1,
max_length=31,
pattern=r"^[A-Za-z0-9\'\" ]+$",
),
]
DishName = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=16),
]
DishDescription = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=80),
]
DigitsOnly = Annotated[str, StringConstraints(pattern=r"^\d+$")]
RoutingNumber = Annotated[str, StringConstraints(pattern=r"^\d{9}$")]
Position = Literal["Chef", "Sous Chef", "Host", "Server", "Delivery Driver"]
# ── Submodelos ─────────────────────────────────────────────────────────────────
class BankDetails(BaseModel):
model_config = ConfigDict(extra="forbid")
routing_number: RoutingNumber
account_number: DigitsOnly
class PaymentDetails(BaseModel):
model_config = ConfigDict(extra="forbid")
mailing_address: NonEmptyStr | None = None
bank_details: BankDetails | None = None
@model_validator(mode="after")
def exactly_one_payment_method(self):
if (self.mailing_address is None) == (self.bank_details is None):
raise ValueError(
"Debe existir exactamente una forma de pago: "
"mailing_address o bank_details"
)
return self
class Dish(BaseModel):
model_config = ConfigDict(extra="forbid")
name: DishName
price_in_cents: PositiveInt
description: DishDescription
picture: str | None = None
class Employee(BaseModel):
model_config = ConfigDict(extra="forbid")
name: NonEmptyStr
position: Position
payment_details: PaymentDetails
# ── Modelo principal ───────────────────────────────────────────────────────────
class Restaurant(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
name: RestaurantName
owner: NonEmptyStr
address: NonEmptyStr
employees: Annotated[list[Employee], Field(min_length=2)]
dishes: Annotated[list[Dish], Field(min_length=3)]
number_of_seats: PositiveInt
to_go: bool
delivery: bool
@field_validator("dishes")
@classmethod
def unique_dish_names(cls, dishes: list[Dish]) -> list[Dish]:
normalized = [dish.name.casefold() for dish in dishes]
if len(normalized) != len(set(normalized)):
raise ValueError("Cada plato debe tener un nombre único")
return dishes
@model_validator(mode="after")
def check_staff_mix(self):
positions = {employee.position for employee in self.employees}
if "Chef" not in positions or "Server" not in positions:
raise ValueError(
"El restaurante debe tener al menos un Chef y un Server"
)
return self
# ── Uso ────────────────────────────────────────────────────────────────────────
with open("restaurant.yaml", "r", encoding="utf-8") as f:
raw_data = yaml.safe_load(f)
restaurant = Restaurant.model_validate(raw_data)
print(restaurant.model_dump())
# Validar una lista completa de restaurantes
adapter = TypeAdapter(list[Restaurant])Sugerencia de estudio: Lee primero las secciones 1–8, implementa el modelo sin mirar el código completo, y deja las secciones 9–13 para repaso y consolidación.