Pydantic Validation

Modelos anidados y listas

Descripción de la cápsula

Hasta ahora tus modelos han sido planos: strings, números, booleanos. En APIs reales necesitas estructuras más ricas: un usuario con dirección, un pedido con una lista de ítems, una factura con líneas de detalle. Modelos anidados y listas de modelos permiten representar esas estructuras de forma tipada y validada.

En esta cápsula aprenderás a anidar un modelo dentro de otro (Address dentro de User), definir listas de modelos (items: list[Item]), manejar modelos opcionales anidados, y construir estructuras complejas como un Order que contiene múltiples OrderItem.


¿Por qué modelos anidados?

Sin anidación, tendrías que aplanar todo en un solo modelo:

# ❌ Incómodo: todo plano
class UserFlat(BaseModel):
    name: str
    street: str
    city: str
    zip_code: str

Con anidación, la estructura refleja el dominio:

# ✅ Clara: estructura jerárquica
class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class User(BaseModel):
    name: str
    address: Address

El JSON que recibe FastAPI puede ser:

{
  "name": "Ana García",
  "address": {
    "street": "Calle Mayor 1",
    "city": "Madrid",
    "zip_code": "28001"
  }
}

Pydantic valida que address sea un objeto con street, city y zip_code. Si falta algún campo, 422 automático.


Tu primer modelo anidado

Definir el modelo interno primero

El modelo que se anida debe estar definido antes (o usar forward reference si hay referencias circulares):

from pydantic import BaseModel, Field


class Address(BaseModel):
    street: str = Field(min_length=1, max_length=200)
    city: str = Field(min_length=1, max_length=100)
    zip_code: str = Field(pattern=r"^\d{5}$", description="Código postal 5 dígitos")


class User(BaseModel):
    name: str = Field(min_length=1)
    email: str = Field(min_length=5)
    address: Address

Usar en un endpoint

from fastapi import FastAPI

app = FastAPI()

@app.post("/users", status_code=201)
def create_user(user: User):
    return user.model_dump()

Request body de ejemplo:

{
  "name": "Carlos Ruiz",
  "email": "carlos@example.com",
  "address": {
    "street": "Av. España 42",
    "city": "Barcelona",
    "zip_code": "08001"
  }
}

Acceder a campos anidados

user = User(
    name="Ana",
    email="ana@test.com",
    address=Address(street="Calle 1", city="Madrid", zip_code="28001")
)
print(user.address.city)  # Madrid
print(user.model_dump())  # Incluye address como dict anidado

Optional anidado

Si la dirección es opcional:

from typing import Optional


class User(BaseModel):
    name: str
    email: str
    address: Optional[Address] = None
  • Si el cliente no envía address o envía null, será None
  • Si envía un objeto, debe cumplir el esquema de Address

Listas de modelos

Para un pedido con múltiples ítems:

from pydantic import BaseModel, Field


class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(gt=0, le=100)
    unit_price: float = Field(ge=0)


class Order(BaseModel):
    customer_id: int = Field(gt=0)
    items: list[OrderItem]

Request body:

{
  "customer_id": 1,
  "items": [
    {"product_id": 101, "quantity": 2, "unit_price": 29.99},
    {"product_id": 102, "quantity": 1, "unit_price": 15.50}
  ]
}

Pydantic valida que items sea una lista y que cada elemento cumpla el esquema de OrderItem. Si algún ítem tiene quantity: -1, 422.


Lista vacía vs lista requerida

# items es obligatorio, pero puede ser []
order: Order = Order(customer_id=1, items=[])

# items opcional con default []
class Order(BaseModel):
    customer_id: int
    items: list[OrderItem] = []  # Si no se envía, lista vacía

Estructuras más complejas

Varios niveles de anidación

class GeoPoint(BaseModel):
    lat: float
    lon: float


class Address(BaseModel):
    street: str
    city: str
    location: Optional[GeoPoint] = None


class User(BaseModel):
    name: str
    address: Address

Lista de modelos anidados con más campos

class OrderItem(BaseModel):
    product_name: str = Field(min_length=1)
    quantity: int = Field(gt=0)
    unit_price: float = Field(ge=0)

    @property
    def subtotal(self) -> float:
        return self.quantity * self.unit_price


class Order(BaseModel):
    order_id: str = Field(min_length=1)
    items: list[OrderItem]
    shipping_address: Address

    def total(self) -> float:
        return sum(item.subtotal for item in self.items)

model_dump con modelos anidados

Por defecto, los modelos anidados se serializan como dicts:

user = User(
    name="Ana",
    email="ana@test.com",
    address=Address(street="Calle 1", city="Madrid", zip_code="28001")
)
user.model_dump()
# {
#   "name": "Ana",
#   "email": "ana@test.com",
#   "address": {"street": "Calle 1", "city": "Madrid", "zip_code": "28001"}
# }

Para excluir campos anidados, usa exclude recursivamente o model_dump(exclude={"address": {"street"}}) en Pydantic v2.


Validación en modelos anidados

Los validadores se aplican en cada nivel. Un @field_validator en Address solo ve los campos de Address. Si necesitas validar la relación entre User y Address, usa un @model_validator en User.

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

    @field_validator("zip_code")
    @classmethod
    def zip_format(cls, v: str) -> str:
        if not v.isdigit() or len(v) != 5:
            raise ValueError("Código postal debe ser 5 dígitos")
        return v


class User(BaseModel):
    name: str
    address: Address

Cuando Pydantic valida User, también valida Address y ejecuta sus validadores.


API completa: Orders con ítems

from fastapi import FastAPI
from pydantic import BaseModel, Field


class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(gt=0, le=100)
    unit_price: float = Field(ge=0)


class Order(BaseModel):
    customer_id: int = Field(gt=0)
    items: list[OrderItem] = Field(min_length=1, description="Al menos un ítem")


app = FastAPI(title="Orders API")
orders_db = []


@app.post("/orders", status_code=201)
def create_order(order: Order):
    order_dict = order.model_dump()
    order_dict["id"] = len(orders_db) + 1
    order_dict["total"] = sum(
        it["quantity"] * it["unit_price"] for it in order.items
    )
    orders_db.append(order_dict)
    return order_dict


@app.get("/orders")
def list_orders():
    return orders_db

items con min_length=1 garantiza que no se cree un pedido vacío.


list[T] vs List[T]

En Python 3.9+ puedes usar list[OrderItem]. En versiones anteriores:

from typing import List

items: List[OrderItem]

FastAPI y Pydantic v2 soportan ambas. Preferir list[T] si usas Python 3.9+.


Dict de modelos

A veces necesitas un dict donde los valores son modelos:

from typing import Dict

class Product(BaseModel):
    name: str
    price: float

class Catalog(BaseModel):
    products: Dict[str, Product]  # key: SKU, value: Product

JSON:

{
  "products": {
    "SKU-001": {"name": "Laptop", "price": 999.99},
    "SKU-002": {"name": "Mouse", "price": 29.99}
  }
}

Cuándo anidar vs aplanar

SituaciónRecomendación
Dirección, ubicación, metadata repetibleAnidar (Address, GeoPoint)
Lista de ítems con estructura propialist[OrderItem]
2-3 campos relacionados que siempre van juntosAnidar
Campos que a veces van juntos, a veces noConsiderar Optional anidado
Solo 1-2 campos "extra" que no se repitenAplanar puede ser suficiente

La regla práctica: si la estructura tiene sentido por sí sola en el dominio, vale un modelo propio.


Cuándo usar nested models: criterios de decisión

Antes de anidar, pregúntate:

  • ¿El grupo de campos tiene identidad propia? Si hablas de "la dirección" o "el ítem del pedido" como una unidad conceptual, un modelo propio encaja.
  • ¿Se reutiliza en varios lugares? Address puede usarse en User, Order, Supplier. Un modelo compartido evita duplicación.
  • ¿La validación es independiente? Si zip_code tiene reglas propias que no dependen de otros campos del padre, un modelo anidado agrupa esa lógica.
  • ¿El JSON del cliente ya viene anidado? Si el frontend envía {"address": {...}}, reflejar esa estructura en el modelo simplifica el contrato.
  • ¿Evitas anidación profunda (>2-3 niveles)? User → Address → GeoPoint está bien. Más niveles dificulta la validación y la documentación en /docs.

Errores comunes

1. Anidar sin BaseModel

Solo puedes anidar modelos que hereden de BaseModel. Un dict o TypedDict no se valida como estructura anidada:

# ❌ Pydantic no valida la estructura de address
class User(BaseModel):
    name: str
    address: dict  # Acepta cualquier dict, sin validar street, city, zip_code

# ✅ Address es BaseModel, se valida correctamente
class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class User(BaseModel):
    name: str
    address: Address

2. Lista sin type hint de modelo

Si usas list sin especificar el tipo de elemento, Pydantic no valida cada ítem:

# ❌ Cada elemento puede ser cualquier cosa
items: list = []

# ✅ Cada elemento debe cumplir OrderItem
items: list[OrderItem] = Field(min_length=1)

3. Referencias circulares sin forward reference

Si User tiene friends: list[User], Python no ha terminado de definir User cuando lo usas. Necesitas forward reference:

from __future__ import annotations

class User(BaseModel):
    name: str
    friends: list[User]  # Con annotations import, funciona

O en versiones anteriores: friends: list["User"].

4. Anti-patrón: anidación muy profunda

Estructuras como Order → Invoice → LineItem → Product → Category → Metadata son difíciles de mantener. Considera aplanar con IDs o referencias cuando pasas de 3 niveles. La documentación en OpenAPI se vuelve ilegible y los clientes tendrán que navegar muchas capas.


Troubleshooting

"value is not a valid dict"

El cliente envió algo que no es un objeto JSON donde se esperaba un modelo anidado. Por ejemplo, "address": "string" en lugar de "address": {"street": "...", ...}. Revisa el body.

Lista vacía cuando esperabas ítems

Si items es requerido pero el cliente envía [], y tienes min_length=1, obtendrás 422. Si no pusiste min_length=1, la lista vacía es válida. Decide según tu regla de negocio.

Forward references para referencias circulares

Si User tiene friends: list[User] y User se define a sí mismo, usa from __future__ import annotations al inicio del archivo o envuelve en comillas: list["User"].

model_dump no serializa bien un modelo anidado

Por defecto sí lo hace. Si usas exclude o include, asegúrate de pasar la estructura correcta para anidados. En Pydantic v2, exclude puede ser un set de keys o un dict para anidados: exclude={"address": {"street"}}.

"List expected" o tipo incorrecto en items

Si definiste items: list[OrderItem] y el cliente envía "items": {"0": {...}} (objeto en lugar de array), Pydantic falla. Verifica que el JSON sea una lista real: [].

El orden de definición importa

Si User usa Address, define Address antes de User. Si hay referencias circulares, usa from __future__ import annotations al inicio del archivo.


Ejercicios

Ejercicio 1: Modelo anidado básico (Fácil)

Define Address (street, city, zip) y Contact (name, email, address). Crea un Contact con una dirección válida. Luego intenta crear uno con address que tenga zip_code vacío.

Ver solución
from pydantic import BaseModel, Field, ValidationError

class Address(BaseModel):
    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    zip_code: str = Field(min_length=5)

class Contact(BaseModel):
    name: str = Field(min_length=1)
    email: str = Field(min_length=5)
    address: Address

# Válido
c = Contact(
    name="Ana",
    email="ana@test.com",
    address=Address(street="Calle 1", city="Madrid", zip_code="28001")
)

# Inválido
try:
    Contact(
        name="Ana",
        email="ana@test.com",
        address=Address(street="Calle 1", city="Madrid", zip_code="")
    )
except ValidationError as e:
    print(e)

Ejercicio 2: Lista de ítems (Medio)

Crea un modelo Invoice con client_name y lines (lista de InvoiceLine). Cada InvoiceLine tiene description, quantity, unit_price. Valida que quantity sea positiva.

Ver solución
from pydantic import BaseModel, Field

class InvoiceLine(BaseModel):
    description: str = Field(min_length=1)
    quantity: int = Field(gt=0)
    unit_price: float = Field(ge=0)

class Invoice(BaseModel):
    client_name: str = Field(min_length=1)
    lines: list[InvoiceLine] = Field(min_length=1)

inv = Invoice(
    client_name="ACME",
    lines=[
        InvoiceLine(description="Servicio A", quantity=2, unit_price=100),
        InvoiceLine(description="Servicio B", quantity=1, unit_price=50),
    ]
)

Ejercicio 3: Optional anidado (Fácil)

Modifica el modelo User para que address sea opcional. ¿Qué pasa si el cliente envía "address": null? ¿Y si no envía el campo address?

Ver solución

En ambos casos user.address será None. Optional[Address] = None permite: (1) no enviar el campo, (2) enviar null. Pydantic asigna None en ambos.

Ejercicio 4: Endpoint con Order (Medio)

Implementa POST /orders que reciba un Order (customer_id + items), calcule el total y lo guarde en memoria. Usa min_length=1 en items.

Ver solución
from fastapi import FastAPI
from pydantic import BaseModel, Field

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(gt=0)
    unit_price: float = Field(ge=0)

class Order(BaseModel):
    customer_id: int = Field(gt=0)
    items: list[OrderItem] = Field(min_length=1)

app = FastAPI()
orders = []

@app.post("/orders", status_code=201)
def create_order(order: Order):
    total = sum(it.quantity * it.unit_price for it in order.items)
    order_dict = order.model_dump()
    order_dict["id"] = len(orders) + 1
    order_dict["total"] = total
    orders.append(order_dict)
    return order_dict

Ejercicio 5: Doble anidación (Medio)

Define GeoPoint (lat, lon), Address (street, city, location: Optional[GeoPoint]), y User (name, address: Address). Crea un User con dirección que tenga location.

Ver solución
from typing import Optional
from pydantic import BaseModel, Field

class GeoPoint(BaseModel):
    lat: float = Field(ge=-90, le=90)
    lon: float = Field(ge=-180, le=180)

class Address(BaseModel):
    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    location: Optional[GeoPoint] = None

class User(BaseModel):
    name: str = Field(min_length=1)
    address: Address

user = User(
    name="Ana",
    address=Address(
        street="Calle 1",
        city="Madrid",
        location=GeoPoint(lat=40.4, lon=-3.7)
    )
)

Ejercicio 6: Validar items no vacíos (Fácil)

Tienes un Order con items: list[OrderItem]. ¿Cómo evitas que se envíe un pedido con items: []?

Ver solución
items: list[OrderItem] = Field(min_length=1)

min_length=1 en una lista asegura que haya al menos un elemento. Pedido vacío → 422.


Resumen

  • ✅ Modelos anidados: usa un modelo como tipo de un campo (address: Address)
  • ✅ Optional anidado: address: Optional[Address] = None
  • ✅ Listas de modelos: items: list[OrderItem] — cada elemento se valida
  • Field(min_length=1) en listas para exigir al menos un elemento
  • ✅ Define el modelo interno antes del que lo usa (o usa forward refs)
  • model_dump() serializa recursivamente los modelos anidados

Próxima cápsula: Request vs Response models — separar modelos de entrada y salida, excluir campos sensibles.


Recursos Adicionales

  1. Pydantic - Nested Models - Modelos anidados
  2. FastAPI - Body - Nested Models - Body con estructuras anidadas
  3. Pydantic - Complex Types - list, dict, Union, etc.
  4. Pydantic - Self-Referencing Models - Referencias circulares
  5. FastAPI - Body - Multiple Parameters - Combinar body anidado con query params
  6. JSON Schema - Arrays - Cómo se representan listas en OpenAPI

Módulo 4, Cápsula 04 — FastAPI Fundamentals Guide