Fragmentos indexados
20
Resource
Python · Listo
Fragmentos indexados
20
Kind
markdown
Attached
no
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 |
---
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.
---
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.
| 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 |
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.
---
dict o un TypedDict no bastaSi 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.
---
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. |
---
AnnotatedUna 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.
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: PaymentDetailsfrom 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:
Field(...).model_validator.extra="forbid".validate_assignment=True.---
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.
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.
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---
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.
---
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.
---
| 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.
---
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.
---
BaseModel para la mayoría de los datos externos.Annotated para adjuntar restricciones de forma legible.StringConstraints para strings y Field(...) para listas, números y metadatos.field_validator para una regla local; usa model_validator para reglas que dependen de varios campos.extra="forbid" si quieres detectar claves inesperadas.validate_assignment=True si quieres revalidar cambios después de crear el objeto.TypeAdapter cuando no necesites un modelo completo.---
delivery=True, debe existir al menos un empleado con posición "Delivery Driver".picture solo acepte extensiones .png, .jpg o .jpeg.list[Restaurant] usando TypeAdapter.BaseModel.strict=True en todo el modelo y analiza qué entradas que antes pasaban ahora fallan.---
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.