Pydantic Validation
BaseModel — El corazón de Pydantic
Descripción de la cápsula
Hasta ahora has recibido datos con dict = Body(...). Eso funciona, pero no valida: cualquiera puede enviar {"year": "abc"} y tu código falla o devuelve basura. Con BaseModel defines un contrato: "esto es exactamente la forma que espero." Pydantic valida antes de que tu endpoint se ejecute. Tipo incorrecto → 422 automático. Campo faltante → 422. Datos válidos → tu función recibe un objeto Python tipado y listo para usar.
Esta cápsula te lleva de cero a dominar BaseModel: crear clases, usarlas en endpoints, entender la validación automática y trabajar con valores por defecto y campos opcionales.
¿Qué hace Pydantic?
Pydantic es una librería de validación de datos basada en type hints. Cuando defines un modelo y pasas datos (dict, JSON string), Pydantic:
- Valida que cada campo cumpla su tipo y restricciones
- Convierte tipos compatibles cuando es posible (
"42"→42,"true"→True) - Rechaza datos inválidos con mensajes claros
- Genera documentación OpenAPI para FastAPI
Todo esto ocurre antes de que tu función de endpoint se ejecute. Si algo falla, FastAPI retorna 422 Unprocessable Entity con el detalle del error — tu código ni siquiera se llama.
Tu primer BaseModel
Un modelo Pydantic es una clase que hereda de BaseModel. Cada atributo con type hint se convierte en un campo validado:
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
| Campo | Tipo | ¿Requerido? | Razón |
|---|---|---|---|
title | str | Sí | Sin valor por defecto |
author | str | Sí | Sin valor por defecto |
year | int | Sí | Sin valor por defecto |
genre | str | Sí | Sin valor por defecto |
available | bool | No | = True → tiene default |
La regla: si un campo tiene valor por defecto, es opcional. Si no, es requerido.
Crear instancias
Con keyword arguments
book = Book(
title="Cien Años de Soledad",
author="Gabriel García Márquez",
year=1967,
genre="Realismo Mágico"
)
print(book.title) # Cien Años de Soledad
print(book.year) # 1967
print(book.available) # True (valor por defecto)
Desde dict o JSON
data = {"title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela Experimental"}
book = Book.model_validate(data)
print(book.author) # Julio Cortázar
json_str = '{"title": "Don Quijote", "author": "Cervantes", "year": 1605, "genre": "Novela"}'
book = Book.model_validate_json(json_str)
print(book.title) # Don Quijote
Coerción automática de tipos
Pydantic convierte tipos compatibles cuando puede:
book = Book(
title="Aura",
author="Fuentes",
year="1962", # str → int
genre="Novela",
available="true" # str → bool
)
print(book.year) # 1962
print(type(book.year)) # <class 'int'>
print(book.available) # True
Si la conversión es imposible ("abc" a int), lanza ValidationError.
Validación: qué pasa con datos inválidos
Tipo incorrecto
from pydantic import ValidationError
try:
Book(title="Ficciones", author="Borges", year="no es número", genre="Cuentos")
except ValidationError as e:
print(e)
1 validation error for Book
year
Input should be a valid integer, unable to parse string as an integer
[type=int_parsing, input_value='no es número', input_type=str]
Campos requeridos faltantes
try:
Book(title="El Aleph", year=1949) # faltan author y genre
except ValidationError as e:
print(e)
Pydantic reporta todos los errores a la vez — el cliente puede corregir todo en un solo intento.
Usar BaseModel en endpoints FastAPI
El cambio fundamental
Con BaseModel, FastAPI detecta automáticamente que el parámetro viene del body. Ya no necesitas Body(). Además valida y documenta:
from fastapi import FastAPI
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
app = FastAPI()
books = []
def generate_id() -> int:
if not books:
return 1
return max(b["id"] for b in books) + 1
@app.get("/books")
def get_books():
return books
@app.post("/books", status_code=201)
def create_book(book: Book):
book_dict = book.model_dump()
book_dict["id"] = generate_id()
books.append(book_dict)
return book_dict
Observa: def create_book(book: Book) — sin Body(). FastAPI infiere que es el body.
Probar con curl
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "El Aleph", "author": "Jorge Luis Borges", "year": 1949, "genre": "Cuentos"}'
Respuesta esperada:
{"title": "El Aleph", "author": "Jorge Luis Borges", "year": 1949, "genre": "Cuentos", "available": true, "id": 1}
La respuesta 422 automática
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Ficciones", "year": "abc"}'
{
"detail": [
{"type": "missing", "loc": ["body", "author"], "msg": "Field required"},
{"type": "missing", "loc": ["body", "genre"], "msg": "Field required"},
{"type": "int_parsing", "loc": ["body", "year"], "msg": "Input should be a valid integer..."}
]
}
Tu función nunca se ejecuta cuando hay errores de validación. FastAPI retorna 422 antes.
model_dump() y model_dump_json()
Para convertir un modelo a dict (por ejemplo para agregar id antes de guardar):
book = Book(title="Pedro Páramo", author="Rulfo", year=1955, genre="Novela")
book_dict = book.model_dump()
book_dict["id"] = 1
# {'title': 'Pedro Páramo', 'author': 'Rulfo', 'year': 1955, 'genre': 'Novela', 'available': True, 'id': 1}
model_dump_json() serializa a string JSON directamente.
Optional y None como default
Campos que pueden ser None:
from typing import Optional
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
notes: Optional[str] = None # Puede ser None, default None
notesno es requerido (tiene default)- Si el cliente no envía
notes, seráNone - Si envía
"notes": nullo"notes": "Algunas notas", Pydantic lo acepta
Campos extra: ignorar o rechazar
Por defecto Pydantic ignora campos que no están definidos en el modelo:
book = Book(
title="Ficciones",
author="Borges",
year=1944,
genre="Cuentos",
pages=174 # No definido en Book — se ignora
)
print(book.model_dump()) # pages no aparece
Para rechazar campos extra:
from pydantic import BaseModel, ValidationError
class StrictBook(BaseModel):
model_config = {"extra": "forbid"}
title: str
author: str
year: int
try:
StrictBook(title="Ficciones", author="Borges", year=1944, pages=174)
except ValidationError as e:
print(e)
# pages — Extra inputs are not permitted [type=extra_forbidden]
API completa de ejemplo
from fastapi import FastAPI
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
app = FastAPI(title="Books API", version="1.0.0")
books_db = [
{"id": 1, "title": "Cien Años de Soledad", "author": "Gabriel García Márquez",
"year": 1967, "genre": "Realismo Mágico", "available": True},
{"id": 2, "title": "Don Quijote", "author": "Miguel de Cervantes",
"year": 1605, "genre": "Novela", "available": True},
]
def generate_id() -> int:
return max(b["id"] for b in books_db) + 1 if books_db else 1
def find_book(book_id: int):
return next((b for b in books_db if b["id"] == book_id), None)
@app.get("/books")
def list_books():
return books_db
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = find_book(book_id)
if book is None:
return {"error": f"Book {book_id} not found"}
return book
@app.post("/books", status_code=201)
def create_book(book: Book):
new_book = book.model_dump()
new_book["id"] = generate_id()
books_db.append(new_book)
return new_book
@app.put("/books/{book_id}")
def update_book(book_id: int, book: Book):
existing = find_book(book_id)
if existing is None:
return {"error": f"Book {book_id} not found"}
updated = book.model_dump()
updated["id"] = book_id
idx = books_db.index(existing)
books_db[idx] = updated
return updated
Ejecuta: uvicorn app.main:app --reload y prueba en /docs.
Diferencias con una clase Python normal
Con una clase Python común, los type hints son solo documentación:
class RegularBook:
def __init__(self, title: str, year: int):
self.title = title
self.year = year
# Python no valida — esto "funciona" y produce basura:
b = RegularBook(title=12345, year="abc") # No hay ValidationError
Con BaseModel, los type hints son reglas que Pydantic aplica siempre. No puedes crear un Book con year="abc" sin que falle.
model_validate vs model_validate_json
| Método | Entrada | Uso típico |
|---|---|---|
model_validate(data) | dict | Datos ya parseados (request body en FastAPI) |
model_validate_json(s) | str (JSON) | Cuando tienes JSON como string (archivo, mensaje) |
FastAPI internamente usa model_validate con el body parseado. Tú normalmente no llamas a estos métodos en endpoints — FastAPI lo hace por ti.
Documentación automática en /docs
Al usar un modelo como tipo del body, FastAPI genera el schema en OpenAPI. En /docs verás:
- El nombre del modelo
- Cada campo con su tipo
- Si es requerido o no
- Valores por defecto
No escribes documentación aparte. El modelo es la documentación.
Cuándo usar BaseModel vs dict
| Situación | Usar |
|---|---|
| Request body con estructura conocida | BaseModel |
| Request body con estructura variable o dinámica | dict con Body() |
| Response que quieres documentar | response_model (Cápsula 05) |
| Datos internos, caché, etc. | dict o BaseModel según prefieras |
Para el 95% de los endpoints REST, BaseModel es la elección correcta.
Troubleshooting
"Field required" pero yo envié el campo
Revisa el nombre exacto (case-sensitive). "Title" no es "title". Revisa que el Content-Type sea application/json.
model_dump() vs dict(book)
Usa book.model_dump(). dict(book) en Pydantic v2 puede no comportarse como esperas. model_dump() es el método oficial para serializar a dict.
¿Pydantic v1 o v2?
FastAPI usa Pydantic v2. La sintaxis de esta guía es v2. Si ves class Config dentro del modelo, eso es v1. En v2 usas model_config = {...}.
ValidationError en lugar de 422
Cuando usas el modelo fuera de FastAPI (en un script, tests), Pydantic lanza ValidationError. Dentro de un endpoint FastAPI, FastAPI captura esa excepción y retorna 422 automáticamente. No necesitas try/except en el endpoint.
Recapitulando: dict vs BaseModel
| Aspecto | dict = Body(...) | BaseModel |
|---|---|---|
| Validación | Ninguna | Automática por tipo y Field |
| Tipo incorrecto | Puede fallar en runtime | 422 antes de ejecutar |
| Documentación /docs | Genérica | Schema exacto por campo |
| Código en endpoint | Verificaciones manuales | Datos ya validados |
| Mantenimiento | Alta fricción | Baja: el modelo es el contrato |
Desde el Módulo 4 en adelante, preferir BaseModel para request body.
Ejercicios
Ejercicio 1: Modelo básico (Fácil)
Define un modelo Product con: name (str), price (float), in_stock (bool, default True). Crea una instancia válida y otra que falle (price como string inválido). ¿Qué error obtienes?
Ver solución
from pydantic import BaseModel, ValidationError
class Product(BaseModel):
name: str
price: float
in_stock: bool = True
# Válido
p = Product(name="Laptop", price=999.99)
print(p.in_stock) # True
# Inválido
try:
Product(name="Laptop", price="muy caro")
except ValidationError as e:
print(e)
# price — Input should be a valid number [type=float_parsing]
Ejercicio 2: Optional con default (Medio)
Crea un modelo User con: username (str), email (str), avatar_url (Optional[str] = None). ¿Qué pasa si el cliente no envía avatar_url? ¿Y si envía "avatar_url": ""?
Ver solución
- No envía
avatar_url: El valor seráNone(usa el default). - Envía
"avatar_url": "": Pydantic acepta""porqueOptional[str]permite str o None. Si quieres distinguir "no enviado" de "string vacío", necesitarías validación adicional (Field o @field_validator).
Ejercicio 3: Endpoint POST (Medio)
Implementa un endpoint POST /products que reciba un modelo Product (name, price, in_stock), genere un id, lo guarde en una lista en memoria y retorne el producto creado con status 201.
Ver solución
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Product(BaseModel):
name: str
price: float
in_stock: bool = True
products = []
@app.post("/products", status_code=201)
def create_product(product: Product):
p_dict = product.model_dump()
p_dict["id"] = len(products) + 1
products.append(p_dict)
return p_dict
Ejercicio 4: Validar 422 (Fácil)
Sin ejecutar, predice: si tu endpoint recibe {"name": "X", "price": -10} para el modelo Product del ejercicio 1, ¿qué status code retornará FastAPI? ¿Se ejecutará la función?
Ver solución
Depende: con el modelo básico (sin Field constraints), Pydantic acepta -10 como float válido. La función sí se ejecutaría y retornaría 201. Para rechazar precios negativos necesitas Field(gt=0) en el campo price — en ese caso retornaría 422 y la función no se ejecutaría.
Ejercicio 5: model_dump con exclude (Medio)
Tienes un Book con notes: Optional[str] = None. Al serializar para la respuesta, quieres excluir los campos que sean None. ¿Cómo lo haces con model_dump()?
Ver solución
book.model_dump(exclude_none=True)
Esto excluye cualquier clave cuyo valor sea None del dict resultante.
Ejercicio 6: Campos extra (Medio)
Quieres que tu modelo Book rechace cualquier campo que no esté definido (por ejemplo si el cliente envía "isbn" por error). ¿Qué configuración agregas al modelo?
Ver solución
class Book(BaseModel):
model_config = {"extra": "forbid"}
title: str
author: str
year: int
genre: str
Con "extra": "forbid", Pydantic lanzará ValidationError si llegan campos no definidos.
Resumen
- ✅ BaseModel: clase que hereda de BaseModel, atributos con type hints = campos validados
- ✅ Sin valor por defecto → requerido; con default → opcional
- ✅ En FastAPI:
def create_x(item: Item)— FastAPI infiere body, valida, documenta - ✅ Dato inválido → 422 antes de ejecutar tu función
- ✅
model_dump()convierte a dict;model_validate()crea desde dict - ✅
Optional[T] = Nonepara campos opcionales que pueden ser None
Próxima cápsula: Field y validators — constraints (min_length, ge, le) y validadores personalizados.
Recursos Adicionales
- Pydantic - BaseModel - Documentación de modelos
- FastAPI - Request Body - Body con Pydantic
- Pydantic - Serialization - model_dump y opciones
Módulo 4, Cápsula 02 — FastAPI Fundamentals Guide