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:

  1. Valida que cada campo cumpla su tipo y restricciones
  2. Convierte tipos compatibles cuando es posible ("42"42, "true"True)
  3. Rechaza datos inválidos con mensajes claros
  4. 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
CampoTipo¿Requerido?Razón
titlestrSin valor por defecto
authorstrSin valor por defecto
yearintSin valor por defecto
genrestrSin valor por defecto
availableboolNo= 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
  • notes no es requerido (tiene default)
  • Si el cliente no envía notes, será None
  • Si envía "notes": null o "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étodoEntradaUso típico
model_validate(data)dictDatos 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ónUsar
Request body con estructura conocidaBaseModel
Request body con estructura variable o dinámicadict con Body()
Response que quieres documentarresponse_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

Aspectodict = Body(...)BaseModel
ValidaciónNingunaAutomática por tipo y Field
Tipo incorrectoPuede fallar en runtime422 antes de ejecutar
Documentación /docsGenéricaSchema exacto por campo
Código en endpointVerificaciones manualesDatos ya validados
MantenimientoAlta fricciónBaja: 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 "" porque Optional[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] = None para campos opcionales que pueden ser None

Próxima cápsula: Field y validators — constraints (min_length, ge, le) y validadores personalizados.


Recursos Adicionales

  1. Pydantic - BaseModel - Documentación de modelos
  2. FastAPI - Request Body - Body con Pydantic
  3. Pydantic - Serialization - model_dump y opciones

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