Module 3: Request and Response
Request Body: JSON, Dict y Combinación de Parámetros
Descripción de la cápsula
En el Módulo 2 usaste Body() para recibir un diccionario en POST. El request body es donde el cliente envía datos complejos — objetos JSON con múltiples campos — en operaciones de creación y actualización. A diferencia del path y la query string, el body no vive en la URL: viaja en el cuerpo del mensaje HTTP, lo que permite enviar estructuras arbitrariamente grandes sin limitaciones de longitud ni caracteres especiales.
En esta cápsula profundizas en cómo FastAPI interpreta el body, las diferencias entre usar dict y un modelo Pydantic (preview), y cómo combinar path parameters, query parameters y body en un solo endpoint. También verás patrones como PUT vs PATCH, body opcional, y body con listas (creación bulk), así como los errores más frecuentes al trabajar con el body.
Al terminar dominarás endpoints como POST /items (solo body), PUT /items/{item_id} (path + body), GET /items?category=X (solo query) y combinaciones como POST /items?notify=true — el repertorio completo de fuentes de datos en una API REST.
Modelo mental: dónde va el body en la petición HTTP
Una petición HTTP tiene una estructura clara. Imagina el mensaje como un sobre:
POST /items?notify=true HTTP/1.1
Host: localhost:8000
Content-Type: application/json
Content-Length: 42
{"name": "Laptop", "price": 999.99}
- Línea de inicio: método + path + query (
POST /items?notify=true) - Headers: metadatos como
Content-Type,Content-Length - Línea en blanco: separa headers del cuerpo
- Body: el JSON que envía el cliente
El body es todo lo que va después de la línea en blanco. No forma parte de la URL, por eso no tiene límite de longitud ni problemas con caracteres especiales. FastAPI lee este bloque cuando declaras un parámetro con Body() o un modelo Pydantic.
Request body: el concepto
Dónde viajan los datos
En una petición HTTP, los datos pueden ir en:
| Ubicación | Ejemplo | Uso típico |
|---|---|---|
| Path | /items/5 | ID del recurso |
| Query | ?skip=0&limit=10 | Filtros, paginación |
| Body | {"name": "X", "price": 99} | Crear/actualizar recursos |
El body es la parte del request que contiene el payload — normalmente JSON. Va en el cuerpo de la petición, no en la URL.
Content-Type
Para enviar JSON, el cliente debe usar el header:
Content-Type: application/json
FastAPI lo espera por defecto. Si envías otro Content-Type, la interpretación puede cambiar.
Recibir JSON con dict y Body()
Sintaxis básica
from fastapi import FastAPI, Body
app = FastAPI()
items = []
@app.post("/items")
def create_item(item: dict = Body(...)):
item["id"] = len(items) + 1
items.append(item)
return item
Body(...)indica que el parámetro viene del body y es obligatorio...(Ellipsis) significa "requerido"- Sin
Body(), FastAPI podría interpretardictde forma ambigua
Probar con curl
curl -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Laptop", "price": 999.99, "category": "Electrónica"}'
Probar en /docs
En la documentación interactiva, el body aparece como un editor JSON. Puedes escribir el objeto y ejecutar. Si tienes múltiples parámetros (por ejemplo body + query), Swagger muestra cada uno en su sección correspondiente; el body se edita en el área "Request body" del ejemplo.
Tips para debugging
- Ver el body recibido: añade temporalmente
print(item)al inicio de la función para inspeccionar qué llega. - Probar con httpx: en tests, usa
client.post("/items", json={"name": "X", "price": 99}); httpx envíaContent-Type: application/jsonautomáticamente. - Logs de uvicorn: si el body no llega, revisa que no haya middleware que consuma el stream antes de que FastAPI lo lea.
Variantes: Body con valores por defecto
Puedes usar Body(default=...) para valores por defecto cuando el body sea opcional:
@app.patch("/items/{item_id}")
def partial_update(item_id: int, updates: dict = Body(default_factory=dict)):
if not updates:
return {"message": "No updates provided"}
# ...
default_factory=dict crea un dict vacío si no se envía body. Alternativamente, Body(None) con Optional[dict] te da None explícitamente cuando no hay body.
¿Por qué Body()?
FastAPI necesita saber de dónde leer cada parámetro:
- Parámetro en la ruta
{param}→ path - Parámetro no en la ruta, tipo simple (str, int, etc.) → query por defecto
- Parámetro complejo (dict, list, modelo Pydantic) → body por defecto solo con Pydantic
Con un dict sin indicación, el comportamiento no siempre es claro. Con Body() le dices explícitamente: "lee esto del body."
Con un modelo Pydantic (Módulo 4), FastAPI asume automáticamente que es body. Por ahora, Body() con dict es la forma segura.
Request body opcional
A veces el body puede ser opcional (por ejemplo, en PATCH para actualización parcial):
from typing import Optional
@app.patch("/items/{item_id}")
def partial_update(item_id: int, updates: Optional[dict] = Body(None)):
if updates is None:
return {"message": "No updates provided"}
# Aplicar updates al item...
return {"item_id": item_id, "updated": updates}
Body(None) — si no se envía body, updates será None.
Pydantic BaseModel: preview
En el Módulo 4 usarás Pydantic para validación robusta. Aquí un adelanto:
from pydantic import BaseModel
class ItemCreate(BaseModel):
name: str
price: float
category: str | None = None
@app.post("/items")
def create_item(item: ItemCreate): # Sin Body() — FastAPI sabe que es body
return {"name": item.name, "price": item.price, "category": item.category}
Con Pydantic:
- No necesitas
Body()— FastAPI asume body automáticamente - Validación automática (tipos, campos requeridos)
- Documentación generada con el esquema del modelo
Por ahora sigue con dict = Body(...); en el Módulo 4 migrarás a modelos Pydantic.
Combinar path + body
El patrón más común: path para identificar el recurso, body para los datos.
PUT: actualizar recurso completo
@app.put("/items/{item_id}")
def update_item(item_id: int, item: dict = Body(...)):
existing = next((i for i in items if i["id"] == item_id), None)
if existing is None:
return {"error": "Item not found"}
index = items.index(existing)
items[index] = {"id": item_id, **item}
return items[index]
item_idviene del pathitemviene del body- FastAPI inyecta ambos correctamente
PATCH: actualización parcial
@app.patch("/items/{item_id}")
def partial_update_item(item_id: int, updates: dict = Body(...)):
existing = next((i for i in items if i["id"] == item_id), None)
if existing is None:
return {"error": "Item not found"}
existing.update({k: v for k, v in updates.items() if k != "id"})
return existing
Combinar path + query + body
Los tres pueden coexistir en el mismo endpoint.
Ejemplo: crear item con metadata en query
@app.post("/items")
def create_item(
item: dict = Body(...),
notify: bool = False, # query param
):
item["id"] = len(items) + 1
items.append(item)
if notify:
# Simular notificación
item["_notified"] = True
return item
curl -X POST "http://127.0.0.1:8000/items?notify=true" \
-H "Content-Type: application/json" \
-d '{"name": "Laptop", "price": 999}'
Ejemplo completo: path + query + body en un solo endpoint
El siguiente endpoint muestra los tres tipos de parámetros en acción. Sirve para crear un item dentro de una categoría existente, con opciones de notificación y modo de validación:
@app.post("/categories/{category_id}/items")
def create_item_in_category(
category_id: int, # PATH: identifica la categoría
item: dict = Body(...), # BODY: datos del item
notify: bool = False, # QUERY: enviar notificación
draft: bool = False, # QUERY: guardar como borrador
):
# Buscar categoría
category = next((c for c in categories if c["id"] == category_id), None)
if category is None:
return {"error": "Category not found"}
# Crear item
item["id"] = generate_id()
item["category_id"] = category_id
item["draft"] = draft
items.append(item)
if notify:
item["_notified"] = True
return item
Petición de ejemplo con curl:
curl -X POST "http://127.0.0.1:8000/categories/3/items?notify=true&draft=false" \
-H "Content-Type: application/json" \
-d '{"name": "Teclado", "price": 79.99}'
- Path
category_id=3→ parte de la URL - Query
notify=true,draft=false→ después del? - Body
{"name": "Teclado", "price": 79.99}→ payload JSON
FastAPI deserializa cada uno desde su fuente y los pasa a la función. No hay ambigüedad: cada parámetro tiene un origen definido.
Orden de prioridad: path, query, body
FastAPI resuelve los parámetros así:
- Path — segmentos en la URL
- Query — parámetros después de
? - Body — contenido del request (JSON)
No hay conflicto: cada parámetro tiene una fuente clara. Lo que debes evitar es tener el mismo nombre en path y query (poco común y confuso).
Body con listas
Puedes recibir una lista en el body:
@app.post("/items/bulk")
def create_items_bulk(items_list: list[dict] = Body(...)):
created = []
for i, item in enumerate(items_list):
item["id"] = len(items) + i + 1
items.append(item)
created.append(item)
return {"created": len(created), "items": created}
curl -X POST http://127.0.0.1:8000/items/bulk \
-H "Content-Type: application/json" \
-d '[{"name": "A", "price": 10}, {"name": "B", "price": 20}]'
Body vacío o malformado
Si el cliente envía:
- Body vacío en un endpoint que espera body → 422
- JSON inválido (sintaxis incorrecta) → 422
- Body con tipo incorrecto (ej. string donde se espera dict) → 422
FastAPI valida antes de ejecutar tu función y retorna detalles del error en español o inglés según configuración. La respuesta 422 incluye un array detail con cada error de validación; por ejemplo, si faltan campos requeridos o hay tipos incorrectos, verás exactamente qué campo falló.
Cuándo usar POST, PUT o PATCH
Elegir el método correcto mejora la semántica de tu API:
| Método | Uso | Body | Efecto |
|---|---|---|---|
| POST | Crear recurso nuevo | Obligatorio, con datos del recurso | Crea un nuevo ítem; normalmente retornas 201 |
| PUT | Reemplazar recurso completo | Obligatorio, representa el recurso completo | Sustituye todo; el body es la nueva versión |
| PATCH | Actualización parcial | Opcional o parcial; solo los campos a cambiar | Modifica solo los campos enviados |
En REST, PUT es idempotente: llamarlo N veces con el mismo body produce el mismo resultado. PATCH también suele ser idempotente por campo. POST no es idempotente: cada llamada puede crear un recurso nuevo.
Ejemplo de respuesta 422
Cuando la validación falla, FastAPI retorna un JSON con detail que describe cada error:
{
"detail": [
{
"loc": ["body"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
Si usas Pydantic, loc puede indicar campos específicos (ej. ["body", "price"]). Esto te ayuda a depurar qué envió mal el cliente.
Código completo de ejemplo
from fastapi import FastAPI, Body
app = FastAPI(
title="Items API - Request Body",
version="1.0.0",
)
items = [
{"id": 1, "name": "Laptop", "price": 999.99, "category": "Electrónica"},
{"id": 2, "name": "Mouse", "price": 29.99, "category": "Accesorios"},
]
def find_item(item_id: int):
return next((i for i in items if i["id"] == item_id), None)
def generate_id():
return max((i["id"] for i in items), default=0) + 1
# Solo body
@app.post("/items")
def create_item(item: dict = Body(...)):
item["id"] = generate_id()
items.append(item)
return item
# Path + body
@app.put("/items/{item_id}")
def update_item(item_id: int, item: dict = Body(...)):
existing = find_item(item_id)
if existing is None:
return {"error": "Item not found"}
index = items.index(existing)
items[index] = {"id": item_id, **item}
return items[index]
# Path + body (parcial)
@app.patch("/items/{item_id}")
def partial_update_item(item_id: int, updates: dict = Body(...)):
existing = find_item(item_id)
if existing is None:
return {"error": "Item not found"}
existing.update({k: v for k, v in updates.items() if k != "id"})
return existing
Errores comunes
Estos son los cinco errores más frecuentes al trabajar con el request body en FastAPI:
-
Olvidar
Body()condict— Si usasitem: dictsinBody(), FastAPI puede inferir el body correctamente en algunos casos, pero el comportamiento no es garantizado. Siempre usaitem: dict = Body(...)para ser explícito. -
Content-Type incorrecto — El cliente debe enviar
Content-Type: application/json. Si envíatext/plainoapplication/x-www-form-urlencoded, FastAPI no parseará el body como JSON y obtendrás 422 o errores inesperados. -
Intentar enviar body en GET — HTTP semánticamente no define un body para GET. Algunos clientes y proxies lo ignoran. Usa POST, PUT o PATCH cuando necesites enviar datos en el body.
-
Dict anidado sin modelo Pydantic — Con
dictno hay validación de estructura. Si esperas{"address": {"city": "...", "zip": "..."}}, un cliente puede enviar cualquier cosa. Para estructuras complejas, migra a Pydantic (Módulo 4). -
No retornar el recurso creado — En POST, es buena práctica retornar el objeto creado (con su ID asignado) y usar status 201. Así el cliente recibe confirmación inmediata sin tener que hacer otro GET.
Conexión con Proyecto
En el proyecto de la Cápsula 06 combinarás path (product ID), query (filtros, paginación) y body (POST/PUT para crear y actualizar productos). Esta cápsula sienta las bases para ese flujo.
Troubleshooting
-
422 al hacer POST — Verifica
Content-Type: application/jsony que el body sea JSON válido. En curl usa-H "Content-Type: application/json"y-d '{"key": "value"}'con comillas simples para evitar que el shell interprete las comillas dobles. -
El body llega vacío o None — Asegúrate de usar
Body(...)para requerido oBody(None)para opcional. SinBody(), undictpuede no resolverse correctamente. -
Conflicto path/query/body — No uses el mismo nombre para parámetros de distintas fuentes en un endpoint. Si tienes
item_iden path yitem_iden body, FastAPI no sabrá cuál usar. -
PATCH sobrescribe todo — Usa
existing.update(updates)para actualización parcial, noexisting = updates. La segunda reemplaza la referencia; la primera modifica el dict in-place. -
ID se pierde en PUT — Incluye el ID explícitamente:
items[index] = {"id": item_id, **item}. El body del cliente puede no incluir el ID; el path es la fuente de verdad. -
Lista vacía en bulk — Si
POST /items/bulkrecibe[], tu loop no hace nada. Decide si quieres retornar{"created": 0, "items": []}o 400 si requieres al menos un item. -
Body como string en curl — En Windows, las comillas pueden complicarse. Usa
-d "{\"name\": \"Laptop\"}"con escapes, o un archivo:-d @payload.json.
Tips para depuración
- Usa
/docspara probar endpoints con body: el editor JSON valida sintaxis y muestra errores de FastAPI. El esquema generado te indica qué campos espera cada endpoint. - Si obtienes 422, revisa el body de la respuesta: FastAPI indica el campo y el problema.
- En desarrollo, imprime
print(item)al inicio de tu función para ver qué llega; quítalo después. - Para estructuras anidadas complejas, considera migrar a Pydantic antes: los errores de validación serán más claros.
- El campo
locen el error 422 indica la ruta del campo:["body", "name"]significa que faltanameen el body. - Con httpx en tests:
response = client.post("/items", json={...})— el parámetrojsonserializa y añadeContent-Typeautomáticamente.
Ejemplo de error 422 típico
{
"detail": [
{
"loc": ["body", "price"],
"msg": "value is not a valid float",
"type": "type_error.float"
}
]
}
loc te dice dónde falló; msg explica el motivo. Úsalo para guiar al cliente al corregir la petición.
Ejercicios
Ejercicio 1: POST con body (Fácil)
Implementa POST /books que reciba {"title": "...", "author": "..."} y lo agregue a una lista con ID auto-generado.
Ver solución
from fastapi import FastAPI, Body
app = FastAPI()
books = []
@app.post("/books")
def create_book(book: dict = Body(...)):
book["id"] = len(books) + 1
books.append(book)
return book
Ejercicio 2: PUT con path y body (Fácil)
Implementa PUT /books/{book_id} que actualice un libro existente usando los datos del body. Retorna error si no existe.
Ver solución
@app.put("/books/{book_id}")
def update_book(book_id: int, book: dict = Body(...)):
existing = next((b for b in books if b["id"] == book_id), None)
if existing is None:
return {"error": "Book not found"}
index = books.index(existing)
books[index] = {"id": book_id, **book}
return books[index]
Ejercicio 3: PATCH parcial (Medio)
Implementa PATCH /books/{book_id} que actualice solo los campos enviados en el body. Si envías {"title": "Nuevo título"}, solo cambia el título.
Ver solución
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
existing = next((b for b in books if b["id"] == book_id), None)
if existing is None:
return {"error": "Book not found"}
existing.update({k: v for k, v in updates.items() if k != "id"})
return existing
Ejercicio 4: Body + query param (Medio)
Implementa POST /items que reciba el item en el body y un query param category: Optional[str] = None. Si category se envía, sobrescribe o complementa la categoría del item.
Ver solución
from typing import Optional
@app.post("/items")
def create_item(item: dict = Body(...), category: Optional[str] = None):
if category is not None:
item["category"] = category
item["id"] = len(items) + 1
items.append(item)
return item
Ejercicio 5: Creación bulk (Medio)
Implementa POST /items/bulk que reciba una lista de items en el body y los cree todos. Retorna {"created": N, "items": [...]}.
Ver solución
@app.post("/items/bulk")
def create_items_bulk(items_list: list[dict] = Body(...)):
created = []
for i, item in enumerate(items_list):
item["id"] = len(items) + i + 1
items.append(item)
created.append(item)
return {"created": len(created), "items": created}
Ejercicio 6: Combinar todo (Difícil)
Implementa PUT /products/{product_id} con path param, body, y query param merge: bool = False. Si merge=True, hace actualización parcial (PATCH-style); si merge=False, reemplaza todo (PUT-style).
Ver solución
@app.put("/products/{product_id}")
def update_product(product_id: int, data: dict = Body(...), merge: bool = False):
existing = next((p for p in products if p["id"] == product_id), None)
if existing is None:
return {"error": "Product not found"}
index = products.index(existing)
if merge:
existing.update({k: v for k, v in data.items() if k != "id"})
products[index] = existing
else:
products[index] = {"id": product_id, **data}
return products[index]
Resumen
- El request body contiene el payload JSON; se usa en POST, PUT, PATCH
- Con
dictnecesitasBody()para indicar que el parámetro viene del body - Path, query y body pueden combinarse en el mismo endpoint sin conflicto
- PATCH usa
dict.update()para actualización parcial; PUT reemplaza el recurso completo - Pydantic (Módulo 4) reemplazará
dictcon modelos tipados y validación automática
Próxima cápsula: Headers, cookies y response — leer headers, configurar respuesta, status codes y response_model.
Recursos Adicionales
- FastAPI - Request Body - Documentación oficial del body
- FastAPI - Body with multiple parameters - Combinar body con query y path
- FastAPI - Body - Nested Models - Estructuras anidadas con Pydantic
- HTTP Message Body - MDN - Especificación HTTP del cuerpo del mensaje
- REST API Design - PUT vs PATCH - Diferencias semánticas entre PUT y PATCH
- JSON.org - Formato JSON: sintaxis y tipos soportados
Módulo 3, Cápsula 04 — FastAPI Fundamentals Guide