Study OS

Modo lectura

Python

Lectura continua de la biblioteca. Cada recurso funciona como un capítulo dentro del libro.

Capítulo 1 de 1

Pydantic

markdown · Listo

Validación en tiempo de ejecución con Pydantic v2

Guía de estudio para Pydantic v2.12.5

NivelIntermedio
ObjetivoEntender por qué validar en tiempo de ejecución y cómo hacerlo bien con Pydantic v2
PúblicoData scientists y desarrolladores Python que trabajan con datos externos
FechaAbril 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

CampoReglas
nameTexto obligatorio, menos de 32 caracteres, solo ASCII, números, comillas, apóstrofe y espacios
ownerTexto obligatorio no vacío
addressTexto obligatorio no vacío
employeesLista con al menos 2 empleados; debe existir al menos un Chef y un Server
payment_detailsCada empleado debe tener exactamente una forma de pago: dirección postal o datos bancarios
dishesLista con al menos 3 platos; cada nombre debe ser único
number_of_seatsEntero positivo
to_go / deliveryBooleanos

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: false

Antes 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))  # dict

Un 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: TypedDict es 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.

HerramientaPara qué sirveCuándo usarla
BaseModelModelos de datos estructuradosTu opción principal para payloads, config y datos externos
FieldMetadatos y restriccionesLongitudes, mínimos, máximos, strict, descripción, alias
AnnotatedAdjuntar restricciones a un tipoPatrón moderno recomendado en v2
StringConstraintsRestricciones específicas para strAlternativa moderna a constr(...)
field_validatorValidar un campo concretoReglas sobre una lista, string o número ya parseado
model_validatorValidar relaciones entre camposReglas que dependen del modelo completo
TypeAdapterValidar tipos arbitrarios sin crear un modeloMuy ú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: PaymentDetails

5.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 self

Por 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 fallaron

Pydantic 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=12

Cuando 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: bool

Regla 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_validatorEl decorador antiguo sigue existiendo pero está deprecado
@root_validator@model_validatorLa 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 value

Para 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 BaseModel para la mayoría de los datos externos.
  • Usa Annotated para adjuntar restricciones de forma legible.
  • Usa StringConstraints para strings y Field(...) para listas, números y metadatos.
  • Usa field_validator para una regla local; usa model_validator para reglas que dependen de varios campos.
  • Usa extra="forbid" si quieres detectar claves inesperadas.
  • Usa validate_assignment=True si quieres revalidar cambios después de crear el objeto.
  • Usa TypeAdapter cuando no necesites un modelo completo.
  • Piensa siempre dónde quieres coerción y dónde prefieres modo estricto.

---

12. Ejercicios

  1. Añade una regla: si delivery=True, debe existir al menos un empleado con posición "Delivery Driver".
  2. Haz que picture solo acepte extensiones .png, .jpg o .jpeg.
  3. Escribe una versión que valide list[Restaurant] usando TypeAdapter.
  4. Convierte el ejemplo principal a dataclasses de Pydantic y compáralo con BaseModel.
  5. Activa strict=True en todo el modelo y analiza qué entradas que antes pasaban ahora fallan.
  6. 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.