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
addresso envíanull, 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ón | Recomendación |
|---|---|
| Dirección, ubicación, metadata repetible | Anidar (Address, GeoPoint) |
| Lista de ítems con estructura propia | list[OrderItem] |
| 2-3 campos relacionados que siempre van juntos | Anidar |
| Campos que a veces van juntos, a veces no | Considerar Optional anidado |
| Solo 1-2 campos "extra" que no se repiten | Aplanar 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?
Addresspuede usarse enUser,Order,Supplier. Un modelo compartido evita duplicación. - ¿La validación es independiente? Si
zip_codetiene 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
- Pydantic - Nested Models - Modelos anidados
- FastAPI - Body - Nested Models - Body con estructuras anidadas
- Pydantic - Complex Types - list, dict, Union, etc.
- Pydantic - Self-Referencing Models - Referencias circulares
- FastAPI - Body - Multiple Parameters - Combinar body anidado con query params
- JSON Schema - Arrays - Cómo se representan listas en OpenAPI
Módulo 4, Cápsula 04 — FastAPI Fundamentals Guide