Module 2: Path Operations
PUT, PATCH and DELETE
Capsule overview
You already know how to read resources with GET and create new ones with POST. Two pieces of the CRUD are still missing: updating and deleting. In this capsule you implement three operations — PUT to replace a whole resource, PATCH to modify just a few fields, and DELETE to remove it. With those three, your API goes from "read-only plus creation" to covering a resource's full life cycle.
The difference between PUT and PATCH is one of the most frequent questions in technical interviews and a constant source of production bugs. PUT receives the complete resource and replaces it — if you leave a field out, it's gone. PATCH receives only the fields you want to change — everything else stays untouched. Understanding when to use each is the difference between an API that works and one that silently corrupts data.
By the end of this capsule you'll have the five HTTP verbs working on your book collection: GET, POST, PUT, PATCH and DELETE. The complete CRUD. All of it on the same in-memory list you built in the previous capsules, with no Pydantic and no advanced error handling — that comes in modules 4 and 5.
PUT: full updates
The concept
PUT means "replace this resource with this new data." You send the complete resource in the request body, and the server swaps the existing resource for what you sent. If you leave a field out, that field disappears.
Think of PUT as erasing a line on a whiteboard and writing it again from scratch. It doesn't matter what it said before — now it says whatever you sent.
Your first PUT endpoint
Open app/main.py. You need the file with the books list and the endpoints from the previous capsules. Add the PUT endpoint:
from fastapi import FastAPI, Body
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental novel"},
]
@app.get("/books")
def get_books():
return books
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
return {"error": "Book not found"}
@app.put("/books/{book_id}", status_code=200)
def update_book(book_id: int, book: dict = Body(...)):
for index, existing_book in enumerate(books):
if existing_book["id"] == book_id:
book["id"] = book_id
books[index] = book
return books[index]
return {"error": "Book not found"}
How it works, step by step
@app.put("/books/{book_id}", status_code=200)— Registers the route for the PUT verb with status code 200book_id: int— Pulls the ID out of the URL and converts it to an integerbook: dict = Body(...)— Reads the request's JSON body as a dictionary- The
forlooks for the book by ID in the list book["id"] = book_id— Forces the ID from the path so it can't be changed from the bodybooks[index] = book— Replaces the whole book in the list- If it doesn't find the book, it returns a dict with an error
Testing it in /docs
Start the server (uvicorn app.main:app --reload) and go to http://127.0.0.1:8000/docs. Find PUT /books/{book_id}, click "Try it out", set book_id = 1 and in the body:
{
"title": "One Hundred Years of Solitude (Commemorative Edition)",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Latin American fiction"
}
Expected response:
{
"title": "One Hundred Years of Solitude (Commemorative Edition)",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Latin American fiction",
"id": 1
}
The danger of PUT: omitted fields
Now try it with an incomplete body:
{
"title": "One Hundred Years of Solitude",
"author": "Gabriel García Márquez"
}
Response:
{
"title": "One Hundred Years of Solitude",
"author": "Gabriel García Márquez",
"id": 1
}
year and genre vanished. PUT replaces everything. You sent only two fields, so the book now has only two fields (plus the forced ID). This isn't a bug — it's PUT behaving correctly. If you want to update only some fields, you need PATCH.
PATCH: partial updates
The concept
PATCH means "modify only these fields of the resource." You send only the fields you want to change, and the server updates those fields while leaving the rest untouched.
If PUT is erasing a line and rewriting it, PATCH is dabbing correction fluid on one word and writing the new one on top. The rest of the line doesn't change.
The PATCH endpoint
Add this to your app/main.py:
@app.patch("/books/{book_id}", status_code=200)
def partial_update_book(book_id: int, updates: dict = Body(...)):
for book in books:
if book["id"] == book_id:
for key, value in updates.items():
if key != "id":
book[key] = value
return book
return {"error": "Book not found"}
How it works, step by step
updates: dict = Body(...)— Receives only the fields to modifyfor key, value in updates.items()— Iterates over each field that was sentif key != "id"— Protects the ID so it can't be changedbook[key] = value— Updates only that field in the existing dictionary- The fields you didn't send are left alone
Testing PATCH
In /docs, find PATCH /books/{book_id}. Use book_id = 3 and send only {"genre": "Experimental fiction"}:
{
"id": 3,
"title": "Hopscotch",
"author": "Julio Cortázar",
"year": 1963,
"genre": "Experimental fiction"
}
title, author and year didn't change — only genre did. You can send one field, two, or all of them; only the ones you send get updated.
PATCH doesn't add validation
With the current implementation, PATCH accepts any field — even fields that don't exist in your original schema:
curl -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"rating": 5, "pages": 417}'
# {"id":1,"title":"One Hundred Years of Solitude","author":"Gabriel García Márquez","year":1967,"genre":"Fiction","rating":5,"pages":417}
rating and pages got added even though they weren't in the original dictionary. That happens because you're working with dicts and no fixed schema. In Module 4, Pydantic solves this by validating which fields are allowed.
DELETE: removing resources
The concept
DELETE removes a resource by its ID. It's the simplest operation: look for the resource, remove it if it exists, return an error if it doesn't.
The DELETE endpoint
Add this to app/main.py:
@app.delete("/books/{book_id}", status_code=200)
def delete_book(book_id: int):
for index, book in enumerate(books):
if book["id"] == book_id:
deleted_book = books.pop(index)
return {"message": "Book deleted", "id": book_id}
return {"error": "Book not found"}
How it works
@app.delete(...)— Registers the route for the DELETE verb- You don't need
Body(...)— DELETE takes no body, just the ID in the URL books.pop(index)— Removes the book from the list and returns it- It returns a confirmation message with the deleted book's ID
Testing DELETE
In /docs, find DELETE /books/{book_id}. Use book_id = 2. Expected response:
{"message": "Book deleted", "id": 2}
Verify with a GET to /books — the book with id: 2 is no longer in the list.
Why status code 200 and not 204?
The "correct" status code for DELETE according to the HTTP specification is 204 (No Content). But 204 means the response has no body — you can't send {"message": "deleted"} or {"error": "not found"} with a 204.
To handle 204 properly you need tools from Module 5 (HTTPException for errors, Response for bodiless responses). For now you use 200 to keep things simple and to be able to return confirmation and error messages.
Comparison: PUT vs PATCH
This is the most important section of this capsule. The difference between PUT and PATCH is a classic interview question and a real source of bugs.
Comparison table
| Aspect | PUT | PATCH |
|---|---|---|
| What you send | The complete resource | Only the fields to modify |
| What happens to omitted fields | They're lost (everything is replaced) | They're untouched (they stay) |
| HTTP semantics | "Replace this resource" | "Modify these fields" |
| Idempotent | Yes — repeating it gives the same result | Depends on the implementation |
| When to use it | Complete forms, syncing | Editing individual fields |
| The risk | Data loss if fields are missing | Unexpected fields with no validation |
Side by side
Imagine a book with this data:
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967, "genre": "Magical realism"}
PUT — You want to change only the genre. You have to send EVERYTHING:
// PUT /books/1 — body:
{"title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967, "genre": "Fiction"}
If you forget year:
// PUT /books/1 — body (no year):
{"title": "One Hundred Years of Solitude", "author": "García Márquez", "genre": "Fiction"}
// Result: year DISAPPEARS from the resource
PATCH — You want to change only the genre. You send ONLY that:
// PATCH /books/1 — body:
{"genre": "Fiction"}
// Result: genre changes, title/author/year stay untouched
When to use each
Use PUT when:
- 📋 The frontend submits a complete form (every field)
- 📋 You need to guarantee the resource holds exactly that data
- 📋 You're syncing data between systems (the source sends the complete state)
Use PATCH when:
- 📋 The user edits a single field (e.g. changing their name in their profile)
- 📋 The operation is a "toggle" (e.g. marking a task as done)
- 📋 The resource has many fields and you're only changing one or two
Which is more common in practice?
PATCH. Most user interfaces let you edit individual fields. A user changes their email — you send a PATCH with just the email. An admin deactivates an account — you send a PATCH with {"active": false}. Sending the whole object with PUT every time you change one field is inefficient and error-prone.
That said, plenty of APIs use PUT for everything and simply ignore the semantic difference. It works, but it isn't the most correct thing to do from a REST standpoint.
Idempotency
PUT is idempotent: running it once or a hundred times produces the same result. PATCH might not be: it depends on the implementation. If your PATCH does {"views": views + 1}, every run increments the value. If it does {"genre": "Fiction"}, repeating it gives the same result — so it is idempotent. In practice, most PATCHes simply assign values and are idempotent, but the HTTP specification doesn't guarantee it.
The complete code
This is app/main.py with all the module's endpoints so far:
from fastapi import FastAPI, Body
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental novel"},
]
@app.get("/books")
def get_books():
return books
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
return {"error": "Book not found"}
@app.post("/books", status_code=201)
def create_book(book: dict = Body(...)):
new_id = max(b["id"] for b in books) + 1 if books else 1
book["id"] = new_id
books.append(book)
return book
@app.put("/books/{book_id}", status_code=200)
def update_book(book_id: int, book: dict = Body(...)):
for index, existing_book in enumerate(books):
if existing_book["id"] == book_id:
book["id"] = book_id
books[index] = book
return books[index]
return {"error": "Book not found"}
@app.patch("/books/{book_id}", status_code=200)
def partial_update_book(book_id: int, updates: dict = Body(...)):
for book in books:
if book["id"] == book_id:
for key, value in updates.items():
if key != "id":
book[key] = value
return book
return {"error": "Book not found"}
@app.delete("/books/{book_id}", status_code=200)
def delete_book(book_id: int):
for index, book in enumerate(books):
if book["id"] == book_id:
books.pop(index)
return {"message": "Book deleted", "id": book_id}
return {"error": "Book not found"}
Connection to the project
The PUT, PATCH and DELETE endpoints you implemented here are the exact pattern you'll use in the To-Do List API of Module 6:
This module (books): Module 6 (tasks):
PUT /books/{id} PUT /tasks/{id}
PATCH /books/{id} PATCH /tasks/{id}
DELETE /books/{id} DELETE /tasks/{id}
The difference is that in Module 6 you'll have Pydantic validating the body's fields (you won't be able to send made-up fields), HTTPException returning real 404 status codes (instead of dicts with "error"), and status code 204 for DELETE (with proper error handling).
What you're building here is the working skeleton. Modules 3–5 put muscle on it.
Troubleshooting
Problem 1: PUT/PATCH returns 422 with no body
Cause: You made a PUT or PATCH request without sending a JSON body. Body(...) is required — the ... (Ellipsis) means "this field is required."
Fix:
# ❌ No body — error 422
curl -X PUT http://127.0.0.1:8000/books/1
# ✅ With a body
curl -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Title", "author": "Author", "year": 2000, "genre": "Fiction"}'
If you test in /docs, Swagger UI always includes the body — this error is more common with curl or hand-rolled HTTP clients.
Problem 2: PUT deletes fields you didn't send
Cause: This isn't a bug — it's PUT behaving correctly. PUT replaces the complete resource.
Fix: If you want to keep the existing fields and change only some of them, use PATCH instead of PUT.
# PUT replaces EVERYTHING — year and genre are lost:
curl -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "One Hundred Years", "author": "GGM"}'
# Result: {"title":"One Hundred Years","author":"GGM","id":1}
# PATCH keeps what's already there:
curl -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "One Hundred Years"}'
# Result: {"id":1,"title":"One Hundred Years","author":"Gabriel García Márquez","year":1967,"genre":"Magical realism"}
Problem 3: PATCH accepts fields that shouldn't exist
Cause: You're using a dict with no fixed schema. Any key you send gets added to the dictionary.
Fix: For now, this is an expected limitation. In Module 4, Pydantic defines a strict schema that only accepts valid fields:
# This works but shouldn't (a made-up field):
curl -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"isbn": "978-0060883287"}'
# "isbn" gets added to the book — Pydantic will prevent this in Module 4
Problem 4: The data is lost when the server restarts
Cause: The data lives in memory (a Python list). When uvicorn restarts, the list is re-initialized with the values from the source code.
Fix: That's the expected behavior for this module. Persistence with databases is covered in later guides of the path. For now, every time you restart you get the original data back, fresh, to keep experimenting with.
Problem 5: DELETE doesn't return the deleted book
Cause: The current implementation returns a confirmation message, not the deleted book. That's a design decision.
Fix: If you need to see what was deleted, save the result of books.pop(index) and add it to the response: return {"message": "Book deleted", "deleted": deleted_book}. Exercise 3 in this capsule implements exactly that.
Exercises
Exercise 1: PUT with protected fields (Easy)
Modify the PUT endpoint so that, on top of id, the created_at field also can't be modified from the body. Assume books have "created_at": "2026-01-15".
See solution
@app.put("/books/{book_id}", status_code=200)
def update_book(book_id: int, book: dict = Body(...)):
for index, existing_book in enumerate(books):
if existing_book["id"] == book_id:
book["id"] = book_id
book["created_at"] = existing_book["created_at"]
books[index] = book
return books[index]
return {"error": "Book not found"}
Even if you send "created_at": "2099-01-01" in the body, the original value "2026-01-15" is preserved because you copy it from the existing book before replacing it.
Exercise 2: PATCH that rejects an empty body (Easy)
Modify the PATCH endpoint so it returns an error if the body is empty (a dictionary with no fields).
See solution
from fastapi import FastAPI, Body
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967, "genre": "Fiction"},
]
@app.patch("/books/{book_id}", status_code=200)
def partial_update_book(book_id: int, updates: dict = Body(...)):
if not updates:
return {"error": "No fields provided for update"}
for book in books:
if book["id"] == book_id:
for key, value in updates.items():
if key != "id":
book[key] = value
return book
return {"error": "Book not found"}
# Empty body
curl -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{}'
# {"error":"No fields provided for update"}
# Body with fields
curl -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"year": 1970}'
# {"id":1,"title":"One Hundred Years of Solitude","author":"García Márquez","year":1970,"genre":"Fiction"}
Explanation: if not updates checks that the dictionary isn't empty before trying to update. An empty dict {} evaluates to False in Python.
Exercise 3: DELETE that returns the updated list (Medium)
Modify the DELETE endpoint so it returns the deletion confirmation, the deleted book, and the complete updated list.
See solution
@app.delete("/books/{book_id}", status_code=200)
def delete_book(book_id: int):
for index, book in enumerate(books):
if book["id"] == book_id:
deleted_book = books.pop(index)
return {
"message": "Book deleted",
"deleted": deleted_book,
"remaining_books": len(books),
"books": books
}
return {"error": "Book not found"}
Expected output when deleting id 2:
{
"message": "Book deleted",
"deleted": {"id": 2, "title": "Don Quixote", "author": "Cervantes", "year": 1605, "genre": "Novel"},
"remaining_books": 2,
"books": [...]
}
Explanation: books.pop(index) returns the element it removed. Including the updated list saves the client another GET.
Exercise 4: PUT with required-field validation (Medium)
Modify PUT so it checks that the body contains title, author, year and genre. If any are missing, return an error saying which ones.
See solution
REQUIRED_FIELDS = ["title", "author", "year", "genre"]
@app.put("/books/{book_id}", status_code=200)
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}
for index, existing_book in enumerate(books):
if existing_book["id"] == book_id:
book["id"] = book_id
books[index] = book
return books[index]
return {"error": "Book not found"}
curl -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Test"}'
# {"error":"Missing required fields","missing_fields":["author","year","genre"]}
Explanation: The list comprehension builds a list of the missing fields. This manual validation is a preview of what Pydantic will do automatically in Module 4.
Exercise 5: A complete movies CRUD (Hard)
Build a complete CRUD API for a collection of movies with the fields: id, title, director, year, rating (a float). Implement GET all, GET by id, POST, PUT, PATCH and DELETE. Include 3 sample movies.
See solution
from fastapi import FastAPI, Body
app = FastAPI()
movies = [
{"id": 1, "title": "Pan's Labyrinth", "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},
]
@app.get("/movies")
def get_movies():
return movies
@app.get("/movies/{movie_id}")
def get_movie(movie_id: int):
for movie in movies:
if movie["id"] == movie_id:
return movie
return {"error": "Movie not found"}
@app.post("/movies", status_code=201)
def create_movie(movie: dict = Body(...)):
new_id = max(m["id"] for m in movies) + 1 if movies else 1
movie["id"] = new_id
movies.append(movie)
return movie
@app.put("/movies/{movie_id}", status_code=200)
def update_movie(movie_id: int, movie: dict = Body(...)):
for index, existing in enumerate(movies):
if existing["id"] == movie_id:
movie["id"] = movie_id
movies[index] = movie
return movies[index]
return {"error": "Movie not found"}
@app.patch("/movies/{movie_id}", status_code=200)
def partial_update_movie(movie_id: int, updates: dict = Body(...)):
for movie in movies:
if movie["id"] == movie_id:
for key, value in updates.items():
if key != "id":
movie[key] = value
return movie
return {"error": "Movie not found"}
@app.delete("/movies/{movie_id}", status_code=200)
def delete_movie(movie_id: int):
for index, movie in enumerate(movies):
if movie["id"] == movie_id:
movies.pop(index)
return {"message": "Movie deleted", "id": movie_id}
return {"error": "Movie not found"}
Try the full flow:
curl http://127.0.0.1:8000/movies # GET all
curl http://127.0.0.1:8000/movies/1 # GET one
curl -X POST http://127.0.0.1:8000/movies \
-H "Content-Type: application/json" \
-d '{"title": "Y tu mamá también", "director": "Alfonso Cuarón", "year": 2001, "rating": 7.7}' # POST
curl -X PATCH http://127.0.0.1:8000/movies/2 \
-H "Content-Type: application/json" \
-d '{"rating": 8.3}' # PATCH
curl -X DELETE http://127.0.0.1:8000/movies/1 # DELETE
Explanation: The pattern is identical to books — the resource's fields change, but each endpoint's structure is the same. CRUD is a reusable pattern: once you master it with one resource, you can apply it to any other.
Exercise 6: A PATCH toggle endpoint (Hard)
Build a PATCH /books/{book_id}/toggle-favorite endpoint that flips a book's favorite field between True and False. If the field doesn't exist, create it as True.
See solution
from fastapi import FastAPI, Body
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967, "genre": "Fiction"},
{"id": 2, "title": "Don Quixote", "author": "Cervantes", "year": 1605, "genre": "Novel"},
]
@app.patch("/books/{book_id}/toggle-favorite", status_code=200)
def toggle_favorite(book_id: int):
for book in books:
if book["id"] == book_id:
current = book.get("favorite", False)
book["favorite"] = not current
return book
return {"error": "Book not found"}
# First call — favorite doesn't exist, it's created as True
curl -X PATCH http://127.0.0.1:8000/books/1/toggle-favorite
# {"id":1,"title":"One Hundred Years of Solitude","author":"García Márquez","year":1967,"genre":"Fiction","favorite":true}
# Second call — toggles to False
curl -X PATCH http://127.0.0.1:8000/books/1/toggle-favorite
# {"id":1,"title":"One Hundred Years of Solitude","author":"García Márquez","year":1967,"genre":"Fiction","favorite":false}
# Third call — toggles back to True
curl -X PATCH http://127.0.0.1:8000/books/1/toggle-favorite
# {"id":1,"title":"One Hundred Years of Solitude","author":"García Márquez","year":1967,"genre":"Fiction","favorite":true}
Explanation: book.get("favorite", False) returns the current value, or False if the field doesn't exist. not current flips it. This pattern is very common: "like" buttons, active/inactive toggles, marking notifications as read. Notice this endpoint doesn't need Body(...) because the action is implicit in the route.
Summary
- PUT replaces a complete resource — if you leave a field out, it's lost
- PATCH modifies only the fields you send — the rest stays untouched
- DELETE removes a resource by its ID
- The PUT vs PATCH difference is a classic interview question: PUT = full replacement, PATCH = partial update
- PUT and DELETE take the ID in the URL (
/books/{book_id}), same as GET by ID - PUT and PATCH receive data in the body with
Body(...); DELETE takes no body - All three operations need to handle the "not found" case — for now with
{"error": "..."}, and in Module 5 withHTTPException - Status codes: 200 for PUT and PATCH (they return the updated resource), 200 for DELETE (it returns a confirmation). The "correct" 204 for DELETE will be implemented in Module 5
Next capsule: Project: CRUD endpoints — You'll pull GET, POST, PUT, PATCH and DELETE together into a complete CRUD with realistic data and end-to-end test flows.
Additional resources
- FastAPI - Body - Request body in FastAPI
- FastAPI - Response Status Code - Setting status codes on endpoints
- HTTP PUT - MDN - The PUT method specification
- HTTP PATCH - MDN - The PATCH method specification
- HTTP DELETE - MDN - The DELETE method specification
- RESTful API Design - PUT vs PATCH - A detailed PUT vs PATCH comparison