Pydantic Validation

Request vs Response models

Descripción de la cápsula

Hasta ahora has usado el mismo modelo para recibir datos y para devolverlos. En APIs reales eso es peligroso: el cliente envía password al registrarse, pero nunca debe recibir la contraseña en las respuestas. Las contraseñas, incluso hasheadas, no deben exponerse: un atacante podría intentar ataques de fuerza bruta o identificar usuarios vulnerables. Separar modelos de request y response es una práctica esencial de seguridad.

Además de passwords, hay datos que el servidor genera (como id, created_at, updated_at) y que el cliente no envía al crear. Usar un solo modelo obliga a hacer campos opcionales de forma confusa o a exponer IDs internos en requests donde no tienen sentido. Por el contrario, separar modelos te permite definir exactamente qué acepta cada endpoint y qué devuelve.

En esta cápsula aprenderás a definir UserCreate (para POST), UserUpdate (para PATCH) y UserResponse (para GET), usar el parámetro response_model en FastAPI, aprovechar model_dump() para excluir campos, y construir APIs que no filtran datos sensibles por error.


¿Por qué separar request y response?

El problema con un solo modelo

class User(BaseModel):
    id: int
    username: str
    email: str
    password: str  # Hash en DB
  • POST /users: El cliente envía username, email, password. No envía id (lo genera el servidor).
  • GET /users/{id}: Devolver password (aunque sea hash) es un riesgo. Nunca expongas contraseñas.

Con un solo modelo, o agregas lógica manual para quitar password en cada respuesta, o te arriesgas a filtrarla mal en algún endpoint.

La solución: modelos separados

class UserCreate(BaseModel):
    username: str = Field(min_length=3)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)


class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    # Sin password
  • Request (POST): UserCreate — lo que el cliente envía
  • Response (GET): UserResponse — lo que el cliente recibe

FastAPI usa cada modelo donde corresponde. No hay forma de que password se filtre por olvido.


UserCreate vs UserResponse

UserCreate — Solo lo que el cliente envía

from pydantic import BaseModel, Field


class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=50)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)

No tiene id — el servidor lo genera. Solo campos que el cliente puede (y debe) enviar.

UserResponse — Solo lo que el cliente recibe

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    is_active: bool = True

No tiene password. Incluye id y cualquier campo calculado o de estado que quieras exponer.


El Patrón Create/Update/Response

En APIs CRUD completas usas tres modelos distintos para usuarios (o cualquier recurso):

UserCreate — Lo que el cliente envía al registrarse

class UserCreate(BaseModel):
    username: str = Field(min_length=3)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)

Incluye password porque el cliente debe enviarlo. No incluye id ni created_at — el servidor los genera.

UserUpdate — Lo que el cliente envía en PATCH

from typing import Optional

class UserUpdate(BaseModel):
    username: Optional[str] = Field(None, min_length=3)
    email: Optional[str] = Field(None, min_length=5)
    password: Optional[str] = Field(None, min_length=8)

Todos los campos son Optional: el cliente envía solo lo que quiere cambiar. Usa model_dump(exclude_unset=True) para aplicar solo los campos presentes.

UserResponse — Lo que el cliente recibe en GET

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    created_at: datetime

Excluye password por completo. Incluye id y created_at porque el servidor los añade.

Resumen: qué va en cada modelo

CampoUserCreateUserUpdateUserResponse
usernameOptional
emailOptional
passwordOptional
id
created_at

Usar response_model en FastAPI

El parámetro response_model le dice a FastAPI qué modelo usar para serializar la respuesta:

from fastapi import FastAPI
from pydantic import BaseModel, Field


class UserCreate(BaseModel):
    username: str = Field(min_length=3)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)


class UserResponse(BaseModel):
    id: int
    username: str
    email: str


app = FastAPI()
users_db: list[dict] = []


@app.post("/users", status_code=201, response_model=UserResponse)
def create_user(user: UserCreate):
    # Simulamos guardar y hashear password
    user_dict = user.model_dump()
    user_dict["id"] = len(users_db) + 1
    user_dict["password"] = "hashed_" + user_dict["password"]  # Nunca lo devolvemos
    users_db.append(user_dict)
    # FastAPI serializa con UserResponse → solo id, username, email
    return user_dict

FastAPI filtra automáticamente: solo incluye los campos definidos en UserResponse. Aunque user_dict tenga password, no aparece en el JSON de respuesta.


Conversión automática con response_model

FastAPI usa el response_model para:

  1. Serializar solo los campos del modelo
  2. Validar que lo que retornas cumpla el esquema (en desarrollo puede alertar si faltan campos)
  3. Documentar el schema de respuesta en OpenAPI (/docs)

Puedes retornar un dict con más campos; FastAPI extrae solo los de UserResponse.


model_dump con exclude e include

Cuando construyes la respuesta manualmente, puedes controlar qué incluir:

user_dict = user.model_dump(exclude={"password"})

O con include para whitelist:

user_dict = user.model_dump(include={"id", "username", "email"})

exclude_unset

Solo incluir campos que el cliente envió (útil para PATCH):

updated = updates.model_dump(exclude_unset=True)

Heredar modelos para evitar duplicación

Si UserResponse y UserCreate comparten campos, puedes heredar:

class UserBase(BaseModel):
    username: str = Field(min_length=3)
    email: str = Field(min_length=5)


class UserCreate(UserBase):
    password: str = Field(min_length=8)


class UserResponse(UserBase):
    id: int
    is_active: bool = True

UserCreate = UserBase + password. UserResponse = UserBase + id + is_active. Sin duplicar username y email.


Excluir None en la respuesta

Si algunos campos son Optional y pueden ser None, quizás no quieras enviarlos en el JSON:

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    avatar_url: Optional[str] = None

Por defecto FastAPI envía "avatar_url": null. Para omitir campos None:

# En el endpoint, si construyes el dict a mano:
return user.model_dump(exclude_none=True)

O configura el modelo con model_config para que la serialización por defecto excluya None (depende de cómo lo uses).


response_model en listas

Para endpoints que retornan listas:

@app.get("/users", response_model=list[UserResponse])
def list_users():
    return users_db

Cada elemento de la lista se serializa con UserResponse. Los campos extra (como password) se filtran automáticamente.


response_model con anidados

Si UserResponse tiene un Address anidado:

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


class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    address: Optional[AddressResponse] = None

FastAPI serializa recursivamente. Asegúrate de que los datos que retornas tengan la estructura esperada.


API completa: Users con Create y Response

from fastapi import FastAPI
from pydantic import BaseModel, Field


class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=50)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)


class UserResponse(BaseModel):
    id: int
    username: str
    email: str


app = FastAPI(title="Users API")
users_db: list[dict] = []


def next_id() -> int:
    return max((u["id"] for u in users_db), default=0) + 1


@app.post("/users", status_code=201, response_model=UserResponse)
def create_user(user: UserCreate):
    data = user.model_dump()
    data["id"] = next_id()
    data["password"] = "hashed_" + data["password"]
    users_db.append(data)
    return data


@app.get("/users", response_model=list[UserResponse])
def list_users():
    return users_db


@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    user = next((u for u in users_db if u["id"] == user_id), None)
    if user is None:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail="User not found")
    return user

En ningún caso password llega al cliente.


response_model_exclude_unset

Para PATCH que solo actualiza campos enviados:

@app.patch("/users/{user_id}")
def partial_update(user_id: int, updates: UserUpdate):
    # updates.model_dump(exclude_unset=True) solo tiene los campos que el cliente envió
    ...

UserUpdate tendría todos los campos opcionales. El cliente envía solo lo que cambia.


response_model y documentación

Al usar response_model, FastAPI actualiza automáticamente la documentación en /docs:

  • El schema de respuesta muestra solo los campos del modelo de respuesta
  • Los ejemplos de respuesta reflejan la estructura correcta
  • Los clientes generados a partir de OpenAPI tendrán tipos precisos

No necesitas mantener documentación aparte para las respuestas.


Múltiples códigos de respuesta

Algunos endpoints retornan diferentes estructuras según el caso (200 vs 404). FastAPI permite responses en el decorador para documentar varios códigos. Para el body exitoso, response_model sigue siendo la forma estándar de definir la estructura.


Errores comunes

1. Un solo modelo para todo

Usar el mismo modelo para request y response obliga a hacer campos opcionales que no tienen sentido (ej. id opcional en request) o a filtrar manualmente en cada respuesta. Separa siempre que haya datos sensibles o campos generados por el servidor.

2. Exponer datos sensibles en la respuesta

Incluir password, api_key, internal_id o tokens en el modelo de respuesta es un riesgo. Aunque no los devuelvas "a mano", si están en el modelo y usas model_dump() sin exclude, pueden filtrarse. La solución: definelos solo en los modelos de request o en modelos internos, nunca en Response.

3. Olvidar response_model en los endpoints

Si retornas un dict con más campos y no pones response_model, FastAPI envía todo. Un único GET sin response_model puede exponer password por olvido. Repasa todos los endpoints que retornan recursos y asegúrate de tener response_model definido.

4. model_dump() sin exclude al construir respuesta manual

Si construyes la respuesta manualmente con user.model_dump() y el modelo tiene password, lo incluirás. Usa model_dump(exclude={"password"}) o, mejor, retorna el dict y deja que FastAPI filtre con response_model.

5. UserUpdate con campos requeridos

En PATCH, el cliente envía solo lo que cambia. Si UserUpdate tiene email: str (requerido), cada PATCH exigirá email aunque solo quieras cambiar el username. Todos los campos de Update deben ser Optional con default None.


Cuándo usar qué

SituaciónModelo
POST/PUT bodyRequest model (UserCreate, BookUpdate)
GET responseResponse model (UserResponse, BookResponse)
Campos que nunca deben exponerseNo ponerlos en Response
Campos que el servidor genera (id, created_at)Solo en Response
PATCH body (parcial)Model con todos los campos Optional

Troubleshooting

"password" aparece en la respuesta

Asegúrate de usar response_model=UserResponse y de que UserResponse no tenga password. Si retornas un dict con password y no usas response_model, FastAPI envía todo.

response_model rompe mi respuesta

Si retornas algo que no tiene los campos del response_model (ej. retornas {"error": "..."} en un 404), FastAPI intentará encajarlo y puede fallar. Para respuestas de error, usa HTTPException o no pongas response_model en ese endpoint específico.

Quiero excluir solo un campo

return user.model_dump(exclude={"password"})

O define un modelo Response sin ese campo y usa response_model.

Heredar de BaseModel con campos extra

Al heredar, el hijo incluye todos los campos del padre. Si UserBase tiene username y UserCreate agrega password, UserCreate tiene ambos. Para Response, hereda de UserBase y agrega id, sin password.

Falta un campo en la respuesta y FastAPI falla

Si usas response_model=UserResponse y retornas un dict sin id (o sin otro campo requerido), Pydantic lanzará ValidationError. Asegúrate de que el objeto que retornas tenga todos los campos del response_model.

response_model con exclude para anidados

Si UserResponse tiene address: Optional[AddressResponse] y quieres excluir un campo del anidado, usa response_model_exclude en el decorador o construye el dict manualmente con exclude anidado.


Resumen de buenas prácticas

  • Siempre separa request y response cuando haya datos sensibles (password, tokens)
  • Usa herencia (UserBase) para reducir duplicación entre modelos relacionados
  • response_model no solo filtra: documenta y valida la forma de tu respuesta
  • Para PATCH, todos los campos Optional + model_dump(exclude_unset=True)
  • Nunca confíes en filtrar campos sensibles "a mano" — usa modelos que no los incluyan

response_model con Union (avanzado)

Para endpoints que retornan diferentes estructuras según el caso (ej. éxito vs error con schema distinto), puedes usar Union[UserResponse, ErrorResponse] o definir responses en el decorador. Para la mayoría de endpoints CRUD, un único response_model es suficiente.


Ejercicios

Ejercicio 1: Crear UserCreate y UserResponse (Fácil)

Define UserCreate (username, email, password) y UserResponse (id, username, email). ¿Por qué UserResponse no tiene password?

Ver solución
from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    username: str = Field(min_length=3)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)

class UserResponse(BaseModel):
    id: int
    username: str
    email: str

UserResponse no tiene password porque las contraseñas (incluso hasheadas) no deben exponerse en respuestas API. Es un riesgo de seguridad.

Ejercicio 2: response_model en POST (Medio)

Implementa POST /users con UserCreate como body y UserResponse como response_model. Guarda en memoria. Verifica en /docs que la respuesta no incluya password.

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

class UserCreate(BaseModel):
    username: str = Field(min_length=3)
    email: str = Field(min_length=5)
    password: str = Field(min_length=8)

class UserResponse(BaseModel):
    id: int
    username: str
    email: str

app = FastAPI()
users = []

@app.post("/users", status_code=201, response_model=UserResponse)
def create_user(user: UserCreate):
    data = user.model_dump()
    data["id"] = len(users) + 1
    data["password"] = "hashed_..."
    users.append(data)
    return data

En /docs, ejecuta el endpoint y revisa la respuesta: solo id, username, email.

Ejercicio 3: model_dump(exclude=...) (Fácil)

Tienes un dict user_data con keys: id, username, email, password. Necesitas enviarlo como respuesta excluyendo password. ¿Cómo?

Ver solución

Opción 1: Si es un BaseModel:

user.model_dump(exclude={"password"})

Opción 2: Si es un dict plano:

{k: v for k, v in user_data.items() if k != "password"}

Opción 3 (mejor): Usar response_model=UserResponse en FastAPI; el modelo no tiene password y FastAPI filtra automáticamente.

Ejercicio 4: Herencia de modelos (Medio)

Crea BookBase con title, author, year. Luego BookCreate (hereda BookBase, sin id) y BookResponse (hereda BookBase, agrega id). Implementa POST /books con ambos modelos.

Ver solución
from pydantic import BaseModel, Field

class BookBase(BaseModel):
    title: str = Field(min_length=1)
    author: str = Field(min_length=1)
    year: int = Field(ge=1000, le=2030)

class BookCreate(BookBase):
    pass

class BookResponse(BookBase):
    id: int

# POST recibe BookCreate, retorna BookResponse con response_model

Ejercicio 5: GET list con response_model (Fácil)

Tu endpoint GET /users retorna una lista de dicts que incluyen password. ¿Cómo evitas exponer password sin cambiar la estructura de cada dict?

Ver solución

Usa response_model=list[UserResponse] en el decorador. FastAPI serializa cada elemento con UserResponse, que no tiene password. No necesitas modificar los dicts internamente.

Ejercicio 6: exclude_none (Medio)

Tienes UserResponse con avatar_url: Optional[str] = None. Al responder, no quieres enviar la key avatar_url cuando es None. ¿Cómo?

Ver solución

Si retornas un dict construido desde el modelo:

return user.model_dump(exclude_none=True)

Si usas response_model=UserResponse, por defecto Pydantic puede incluir null. Para excluir None en la serialización del modelo, puedes usar model_config con ser_json_timedelta o un serializer personalizado. La forma más directa es model_dump(exclude_none=True) cuando construyes el dict manualmente, o ajustar el modelo con configuración de serialización que omita None.


Resumen

  • ✅ Separa modelos: UserCreate para request, UserResponse para response
  • response_model=UserResponse filtra automáticamente campos no definidos en el modelo
  • ✅ Nunca incluyas password (ni hashes) en modelos de respuesta
  • model_dump(exclude={"password"}) o include={...} para control manual
  • ✅ Hereda con UserBase para evitar duplicar campos comunes
  • response_model=list[UserResponse] para listas de recursos

Próxima cápsula: Proyecto — API de contactos con validación completa, modelos anidados y request/response separados.


Recursos Adicionales

  1. FastAPI - Response Model - response_model
  2. Pydantic - model_dump - exclude, include
  3. FastAPI - Extra Models - Múltiples modelos
  4. FastAPI - Declare Request Example Data - Ejemplos en request vs response
  5. Pydantic - Serialization - exclude_none, exclude_unset
  6. OWASP - API Security - Buenas prácticas de seguridad en APIs

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