Module 2: Path Operations
PUT y PATCH: Actualizar Recursos
Descripción de la cápsula
Ya sabes leer recursos con GET y crear nuevos con POST. Te falta una pieza fundamental del CRUD: actualizar. En esta cápsula implementas dos operaciones distintas — PUT para reemplazar un recurso completo, y PATCH para modificar solo algunos campos. La diferencia entre ambas es una de las preguntas más frecuentes en entrevistas técnicas y una fuente constante de bugs en producción.
PUT recibe el recurso completo y lo reemplaza — si omites un campo, se pierde. PATCH recibe solo los campos que quieres cambiar — el resto permanece intacto. Entender cuándo usar cada uno es la diferencia entre una API que funciona y una que corrompe datos silenciosamente.
Al terminar esta cápsula tendrás endpoints PUT y PATCH funcionando sobre tu colección de libros, con la misma lista en memoria de las cápsulas anteriores.
PUT: Actualización completa (reemplazo)
El concepto
PUT significa "reemplaza este recurso con estos datos nuevos." Envías el recurso completo en el body del request, y el servidor sustituye el recurso existente con lo que enviaste. Si omites un campo, ese campo desaparece.
Piensa en PUT como borrar una línea en una pizarra y escribirla de nuevo desde cero. No importa lo que decía antes — ahora dice lo que tú mandaste.
Tu primer endpoint PUT
Abre app/main.py con la lista de libros y los endpoints GET y POST. Agrega el endpoint PUT:
from fastapi import FastAPI, Body
# ... (books list, generate_id, GET, POST)
def find_book(book_id: int):
"""Busca un libro por ID. Retorna None si no existe."""
return next((b for b in books if b["id"] == book_id), None)
@app.put("/books/{book_id}")
def update_book(book_id: int, book: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
# Reemplaza el libro completo, preservando el ID
index = books.index(existing)
books[index] = {"id": book_id, **book}
return books[index]
Cómo funciona paso a paso
@app.put("/books/{book_id}")— Registra la ruta para el verbo PUTbook_id: int— Extrae el ID de la URLbook: dict = Body(...)— Lee el cuerpo JSON del requestfind_book()busca el libro por ID- Si no existe, retorna error
books[index] = {"id": book_id, **book}— Reemplaza el libro completo, forzando el ID del path para que no se pueda cambiar desde el body- Retorna el libro actualizado
Probar en /docs
Ve a http://127.0.0.1:8000/docs. Busca PUT /books/{book_id}, haz clic en "Try it out", pon book_id = 1 y en el body:
{
"title": "Cien años de soledad (Edición Conmemorativa)",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Ficción latinoamericana"
}
Respuesta esperada:
{
"id": 1,
"title": "Cien años de soledad (Edición Conmemorativa)",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Ficción latinoamericana"
}
El peligro de PUT: campos omitidos
Prueba con un body incompleto:
{
"title": "Cien años de soledad",
"author": "Gabriel García Márquez"
}
Respuesta:
{
"id": 1,
"title": "Cien años de soledad",
"author": "Gabriel García Márquez"
}
year y genre desaparecieron. PUT reemplaza todo. Enviaste solo dos campos, así que el libro ahora solo tiene dos campos (más el ID). Esto no es un bug — es el comportamiento correcto de PUT. Si quieres actualizar solo algunos campos, necesitas PATCH.
PATCH: Actualización parcial
El concepto
PATCH significa "modifica solo estos campos del recurso." Envías únicamente los campos que quieres cambiar, y el servidor actualiza esos campos dejando los demás intactos.
Si PUT es borrar una línea y reescribirla, PATCH es usar corrector líquido sobre una palabra y escribir la nueva encima. El resto de la línea no cambia.
Endpoint PATCH
Agrega esto a tu app/main.py:
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
# Solo actualiza los campos proporcionados (no permite cambiar el ID)
for key, value in updates.items():
if key != "id":
existing[key] = value
return existing
Cómo funciona paso a paso
updates: dict = Body(...)— Recibe solo los campos a modificarfor key, value in updates.items()— Itera sobre cada campo enviadoif key != "id"— Protege el ID para que no se pueda cambiarexisting[key] = value— Actualiza solo ese campo en el diccionario existente- Los campos que no enviaste no se tocan
Probar PATCH
En /docs, busca PATCH /books/{book_id}. Usa book_id = 3 y envía solo:
{
"genre": "Ficción experimental"
}
title, author y year no cambiaron — solo genre. Puedes enviar uno, dos, o todos los campos; solo los enviados se actualizan.
PATCH con .update()
Una forma más concisa de hacer lo mismo:
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
# .update() aplica solo las keys enviadas
updates_copy = {k: v for k, v in updates.items() if k != "id"}
existing.update(updates_copy)
return existing
Comparación directa PUT vs PATCH
Antes de profundizar en la comparación general, conviene tener clara la distinción en un vistazo. Esta tabla resume semántica, body y casos de uso:
| Aspecto | PUT | PATCH |
|---|---|---|
| Semántica | "Reemplaza este recurso con exactamente este estado" | "Aplica estos cambios al recurso" |
| Body esperado | Objeto completo (todos los campos definidos) | Objeto parcial (solo campos a modificar) |
| Campos omitidos en el body | Se eliminan del recurso | Permanecen sin cambios |
| Caso de uso típico | Formulario de edición completo, sincronización de estado | Actualización de un campo, toggle booleano, corrección puntual |
| Riesgo si mal usas | Borrar datos sin querer al omitir campos | Agregar campos no validados o corromper estructura |
En resumen: PUT = reemplazo total; PATCH = actualización incremental.
Comparación: PUT vs PATCH
Esta es la sección más importante de esta cápsula.
Tabla comparativa
| Aspecto | PUT | PATCH |
|---|---|---|
| Qué envías | Recurso completo | Solo los campos a modificar |
| Qué pasa con campos omitidos | Se pierden (reemplazo total) | No se tocan (permanecen) |
| Semántica HTTP | "Reemplaza este recurso" | "Modifica estos campos" |
| Idempotente | Sí — repetir da el mismo resultado | Depende de la implementación |
| Cuándo usarlo | Formularios completos, sincronización | Edición de campos individuales |
| Riesgo | Pérdida de datos si faltan campos | Campos inesperados sin validación |
Ejemplo lado a lado
Libro inicial:
{"id": 1, "title": "Cien años de soledad", "author": "García Márquez", "year": 1967, "genre": "Realismo mágico"}
PUT — Cambiar solo el género. Debes enviar TODO:
// PUT /books/1 — body:
{"title": "Cien años de soledad", "author": "García Márquez", "year": 1967, "genre": "Ficción"}
Si olvidas year:
// PUT /books/1 — body sin year:
{"title": "Cien años de soledad", "author": "García Márquez", "genre": "Ficción"}
// Resultado: year DESAPARECE del recurso
PATCH — Cambiar solo el género. Envías SOLO eso:
// PATCH /books/1 — body:
{"genre": "Ficción"}
// Resultado: genre cambia, title/author/year permanecen intactos
¿Cuándo usar cada uno?
Usa PUT cuando:
- El frontend envía un formulario completo (todos los campos)
- Necesitas garantizar que el recurso tenga exactamente esos datos
- Sincronizas datos entre sistemas (el origen manda el estado completo)
Usa PATCH cuando:
- El usuario edita un solo campo (ej: cambiar nombre en el perfil)
- La operación es "toggle" (ej: marcar una tarea como completada)
- El recurso tiene muchos campos y solo cambias uno o dos
¿Cuál es más común en la práctica?
PATCH. La mayoría de interfaces permiten editar campos individuales. Un usuario cambia su email — mandas PATCH con solo el email. Un admin desactiva una cuenta — mandas PATCH con {"active": false}. Mandar el objeto completo con PUT cada vez es ineficiente y propenso a errores.
Buscar por ID: La función find_book
Ambos endpoints (PUT y PATCH) necesitan encontrar el libro por ID. Centralizar esa lógica en find_book() evita duplicación:
def find_book(book_id: int):
return next((b for b in books if b["id"] == book_id), None)
Usas find_book(book_id) en PUT y PATCH. Si retorna None, el libro no existe. Si retorna el dict, puedes modificarlo o reemplazarlo.
PATCH no agrega validación
Con la implementación actual, PATCH acepta cualquier campo — incluso campos que no existen en tu esquema original:
curl -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"rating": 5, "pages": 417}'
Se agregarían rating y pages al libro. Esto ocurre porque estás trabajando con dicts sin esquema fijo. En el Módulo 4, Pydantic validará qué campos son permitidos.
Status codes para PUT y PATCH
Ambos retornan 200 OK por defecto cuando la actualización es exitosa. No necesitas configurar nada. Cuando el recurso no existe, tu código retorna {"error": "Book not found"} con status 200 — en el Módulo 5 usarás HTTPException(status_code=404) para retornar un 404 real.
Mutación in-place vs reemplazo
PUT reemplaza el objeto: books[index] = {...}. Creas un nuevo diccionario y lo asignas.
PATCH muta el objeto existente: existing[key] = value o existing.update(updates). Modificas el mismo diccionario que ya está en la lista. Ambas estrategias son válidas; la clave es que PATCH no toque los campos que el cliente no envió.
Preservar el ID en PUT
Es crítico que el cliente no pueda cambiar el ID desde el body. Por eso hacemos:
books[index] = {"id": book_id, **book}
El book_id viene del path (fuente confiable). Si el cliente envía {"id": 999, "title": "..."} en el body, ignoramos el 999 y usamos el del path. El orden {"id": book_id, **book} asegura que nuestro ID tenga prioridad.
Probar PUT y PATCH con curl
PUT completo
curl -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{
"title": "Cien años de soledad",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Realismo mágico"
}'
PATCH parcial
curl -X PATCH http://127.0.0.1:8000/books/2 \
-H "Content-Type: application/json" \
-d '{"genre": "Novela clásica"}'
Solo el campo genre cambia. Los demás permanecen igual.
Verificar el resultado
Después de PUT o PATCH, haz un GET al mismo ID para confirmar que los cambios se aplicaron correctamente.
Orden de prioridad en PUT: path sobre body
En PUT, el book_id del path tiene prioridad absoluta sobre cualquier id que venga en el body. Si un cliente envía PUT /books/1 con body {"id": 999, "title": "..."}, el servidor debe ignorar el 999 y usar el 1. Por eso construimos {"id": book_id, **book}: el orden asegura que nuestra clave id no sea sobrescrita por la del body. En PATCH, la protección es explícita con if key != "id".
Diferencias prácticas en el cliente
Cuando el frontend consume tu API, debe decidir qué método usar en cada pantalla. Un formulario de edición completa (todos los campos visibles y editables) suele enviar PUT con el objeto completo. Un dropdown que cambia solo el estado de una tarea (completed: true/false) enviará PATCH con {"completed": true}. Si el cliente mantiene un estado local y sincroniza con el servidor, PUT es preferible para evitar desfases; si solo refleja cambios puntuales del usuario, PATCH es más eficiente.
Cuándo falla la búsqueda por ID
Si find_book(book_id) retorna None, el libro no existe. Las causas típicas:
- El cliente envió un ID que nunca existió (ej: 999)
- El libro fue eliminado con DELETE en una operación anterior
- Los IDs no coinciden por tipo (str vs int) — pero con
book_id: inten el path, FastAPI ya valida que sea entero
Siempre verifica if existing is None antes de operar.
PUT vs PATCH: decisión en el diseño de la API
Al diseñar tu API, documenta qué método espera cada endpoint y qué estructura de body. Si un endpoint acepta ambos (algunas APIs lo hacen), indica claramente la diferencia en la documentación. En OpenAPI (Swagger), puedes describir en el summary o description del endpoint: "PUT reemplaza el recurso completo; envía todos los campos. PATCH actualiza solo los campos enviados." Esto evita que los consumidores de tu API cometan errores por confusión entre ambos métodos.
Errores comunes
Al implementar PUT y PATCH, estos errores aparecen con frecuencia. Evitarlos te ahorra horas de depuración:
-
Confundir PUT con PATCH — Usar PUT cuando solo quieres cambiar un campo y enviar solo ese campo provoca que el resto desaparezca. Si tu intención es actualizar parcialmente, usa PATCH y envía únicamente los campos a modificar.
-
No preservar el ID del path en PUT — Si haces
books[index] = booksin forzarbook["id"] = book_id, el cliente podría enviar un ID distinto en el body y corromper la estructura. Siempre construye con{"id": book_id, **book}para que el ID de la URL tenga prioridad. -
Permitir que PATCH modifique el ID — Sin la condición
if key != "id", el cliente podría enviar{"id": 999}y romper la coherencia de la colección. Siempre excluye el ID de las actualizaciones en PATCH. -
Enviar PUT con body incompleto pensando que es PATCH — Si el frontend usa PUT pero solo envía los campos modificados, perderás datos. Verifica qué método estás usando y qué estructura de body espera cada uno.
Comparación directa PUT vs PATCH
Esta tabla resume la diferencia entre ambos métodos en un vistazo:
| Criterio | PUT | PATCH |
|---|---|---|
| Semántica | Reemplazo completo del recurso | Modificación parcial |
| Body | Obligatorio y completo (todos los campos) | Obligatorio pero solo campos a cambiar |
| Campos omitidos | Se borran del recurso | No se tocan |
| Idempotencia | Sí — N ejecuciones = mismo resultado | Depende (toggle no es idempotente) |
| Uso típico | Formularios, sincronización, reemplazo total | Ediciones puntuales, toggles, activar/desactivar |
Conexión con Proyecto
Los endpoints PUT y PATCH que implementaste aquí son el patrón exacto del To-Do List API (Módulo 6). La diferencia es que en el Módulo 6 tendrás Pydantic validando los campos del body.
Resumen rápido: cuándo usar cada método
Si tienes dudas al diseñar tu API, usa esta guía:
- PUT — El cliente tiene el estado completo del recurso y quiere reemplazarlo. El servidor no mantiene estado; el cliente es la fuente de verdad.
- PATCH — El cliente solo conoce algunos campos y quiere actualizarlos. El servidor mantiene el resto intacto. Es la opción más común en UIs de edición.
Troubleshooting
- PUT/PATCH retorna 422 sin body —
Body(...)es obligatorio. Envía un body JSON válido. En/docs, Swagger siempre incluye el body. - PUT elimina campos que no enviaste — No es un bug. Es el comportamiento de PUT. Usa PATCH si quieres conservar campos existentes.
- PATCH acepta campos inventados — Limitación esperada con dicts. Pydantic (Módulo 4) lo resolverá.
- Los datos se pierden al reiniciar — Esperado con almacenamiento en memoria.
- 404 o "Book not found" con status 200 — Tu código retorna
{"error": "Book not found"}con status 200. En el Módulo 5 usarásHTTPException(status_code=404)para respuestas REST correctas. - PUT y PATCH devuelven el mismo recurso pero con datos distintos — Revisa que en PUT usas
{"id": book_id, **book}(reemplazo) y en PATCH usasexisting.update(...)(mutación parcial). - El cliente envía PUT pero espera comportamiento de PATCH — Si el frontend solo manda los campos modificados con PUT, perderás el resto. Coordina con el equipo frontend: PUT = objeto completo, PATCH = solo cambios.
Ejercicios
Ejercicio 1: PUT con protección de campos (Fácil)
Modifica el endpoint PUT para que, además del id, el campo created_at tampoco pueda ser modificado desde el body. Asume que los libros tienen "created_at": "2026-01-15".
Ver solución
@app.put("/books/{book_id}")
def update_book(book_id: int, book: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
index = books.index(existing)
book["id"] = book_id
book["created_at"] = existing["created_at"]
books[index] = book
return books[index]
Ejercicio 2: PATCH que no acepta body vacío (Fácil)
Modifica el endpoint PATCH para que retorne un error si el body está vacío (diccionario sin campos).
Ver solución
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
if not updates:
return {"error": "No fields provided for update"}
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
for key, value in updates.items():
if key != "id":
existing[key] = value
return existing
Ejercicio 3: PUT con validación de campos requeridos (Medio)
Modifica PUT para que verifique que el body contiene title, author, year y genre. Si falta alguno, retorna un error indicando cuáles faltan.
Ver solución
REQUIRED_FIELDS = ["title", "author", "year", "genre"]
@app.put("/books/{book_id}")
def update_book(book_id: int, book: dict = Body(...)):
missing = [field for field in REQUIRED_FIELDS if field not in book]
if missing:
return {"error": "Missing required fields", "missing_fields": missing}
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
index = books.index(existing)
books[index] = {"id": book_id, **book}
return books[index]
Ejercicio 4: PATCH solo permite campos conocidos (Medio)
Modifica PATCH para que solo actualice los campos title, author, year, genre. Si el cliente envía un campo desconocido, ignóralo (no lo agregues al libro).
Ver solución
ALLOWED_FIELDS = {"title", "author", "year", "genre"}
@app.patch("/books/{book_id}")
def partial_update_book(book_id: int, updates: dict = Body(...)):
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
for key, value in updates.items():
if key in ALLOWED_FIELDS:
existing[key] = value
return existing
Ejercicio 5: CRUD de películas con PUT y PATCH (Difícil)
Crea una API CRUD para películas (id, title, director, year, rating) con GET, POST, PUT y PATCH. Incluye 3 películas de ejemplo.
Ver solución
movies = [
{"id": 1, "title": "El laberinto del fauno", "director": "Guillermo del Toro", "year": 2006, "rating": 8.2},
{"id": 2, "title": "Amores perros", "director": "Alejandro González Iñárritu", "year": 2000, "rating": 8.1},
{"id": 3, "title": "Roma", "director": "Alfonso Cuarón", "year": 2018, "rating": 7.7},
]
def find_movie(movie_id: int):
return next((m for m in movies if m["id"] == movie_id), None)
@app.get("/movies")
def get_movies():
return movies
@app.get("/movies/{movie_id}")
def get_movie(movie_id: int):
movie = find_movie(movie_id)
if movie is None:
return {"error": "Movie not found"}
return movie
@app.post("/movies", status_code=201)
def create_movie(movie: dict = Body(...)):
movie["id"] = max(m["id"] for m in movies) + 1 if movies else 1
movies.append(movie)
return movie
@app.put("/movies/{movie_id}")
def update_movie(movie_id: int, movie: dict = Body(...)):
existing = find_movie(movie_id)
if existing is None:
return {"error": "Movie not found"}
index = movies.index(existing)
movies[index] = {"id": movie_id, **movie}
return movies[index]
@app.patch("/movies/{movie_id}")
def partial_update_movie(movie_id: int, updates: dict = Body(...)):
existing = find_movie(movie_id)
if existing is None:
return {"error": "Movie not found"}
for key, value in updates.items():
if key != "id":
existing[key] = value
return existing
Ejercicio 6: Endpoint PATCH toggle (Difícil)
Crea un endpoint PATCH /books/{book_id}/toggle-favorite que alterne el campo favorite entre True y False. Si el campo no existe, lo crea como True.
Ver solución
@app.patch("/books/{book_id}/toggle-favorite")
def toggle_favorite(book_id: int):
existing = find_book(book_id)
if existing is None:
return {"error": "Book not found"}
current = existing.get("favorite", False)
existing["favorite"] = not current
return existing
Este endpoint no necesita Body(...) porque la acción está implícita en la ruta.
Resumen
- PUT reemplaza un recurso completo — si omites un campo, se pierde
- PATCH modifica solo los campos enviados — el resto permanece intacto
- La diferencia PUT vs PATCH es una pregunta clásica de entrevistas
- PUT y PATCH reciben datos en el body con
Body(...) - Usa
find_book()para buscar por ID antes de operar - Preserva el ID en PUT:
{"id": book_id, **book} - PATCH con
.update()o iterando sobre los campos enviados
Próxima cápsula: DELETE: Eliminar — Completarás el CRUD eliminando recursos y manejando status codes 200/204.
Resumen de códigos de ejemplo
PUT — Reemplazo completo:
books[index] = {"id": book_id, **book}
PATCH — Actualización parcial (opción 1):
for key, value in updates.items():
if key != "id":
existing[key] = value
PATCH — Actualización parcial (opción 2):
existing.update({k: v for k, v in updates.items() if k != "id"})
La opción 2 es más concisa. Ambas protegen el ID.
Recursos Adicionales
- FastAPI - Body - Request body en FastAPI
- HTTP PUT - MDN - Especificación del método PUT
- HTTP PATCH - MDN - Especificación del método PATCH
- RESTful API Design - PUT vs PATCH - Comparación detallada
- RFC 7231 - PUT - Especificación oficial HTTP para PUT
- RFC 5789 - PATCH - Especificación oficial del método PATCH
Módulo 2, Cápsula 04 — FastAPI Fundamentals Guide