Path Operations and HTTP Methods
Build a small to-do API with all five common HTTP methods - GET, POST, PUT, PATCH and DELETE - and the right status codes. Then test the mistakes: a wrong method, a trailing slash, routes in the wrong order, and a route defined twice.
What you will be able to do
- Explain what a path operation is: a method, a path and a function
- Use GET, POST, PUT, PATCH and DELETE for what each one means
- Set the right status code: 200, 201, 204, 404
- Test an API quickly with TestClient
- Explain 404, 405, 307 and 422 when you see them
- Order routes correctly, and avoid duplicates
The idea, in plain English
In Lesson 1.2 every route used @app.get. But an API does more than read. It creates things, changes them and deletes them. HTTP has a method for each job: GET reads, POST creates, PUT replaces, PATCH changes part of something, DELETE removes. FastAPI has one decorator for each: @app.get, @app.post, @app.put, @app.patch and @app.delete.
A method plus a path is called a path operation. GET /todos and POST /todos have the same path but are two different path operations - one lists the to-dos, the other creates one. Each has its own Python function.
In this lesson we build a small to-do API that keeps its data in a Python dict (a real database comes in Module 5). We test every operation, set the right status codes, and then try the mistakes that confuse beginners: the wrong method, a slash at the end, routes in the wrong order, and the same route twice. FastAPI 0.143.0, Starlette 1.7.0.
Worked example: An in-memory to-do API: list, read, create (201), replace, update (exercise) and delete (204) - tested with TestClient and curl.
1 - Create: POST /todos -> 201
The client sends {"title": "Call the bank"}. create_todo gives it id 2 and answers 201 Created with the new to-do.
Real runs of the to-do API. The method decides which function runs, and each answers with its own status code.
Words you will see in this lesson
A few words about routes and methods.
PathThe part of the URL after the address: /todos, /todos/2.HTTP methodWhat the client wants to do: GET, POST, PUT, PATCH, DELETE.Path operationA method plus a path, handled by one function: POST /todos.CRUDCreate, Read, Update, Delete - the four basic jobs of most APIs.Status codeThe result number: 200 OK, 201 Created, 204 No Content, 404, 405, 422.IdempotentDoing it twice has the same effect as doing it once (PUT, DELETE).TestClientSends real requests to your app inside Python - no server needed.An everyday example: a library desk
At a library desk, the same book number can mean different things depending on what you ask. "Show me book 42" is reading (GET). "Please add this new book" is creating (POST). "Replace the record for book 42 with this one" is PUT. "Just change the shelf number of book 42" is PATCH. "Remove book 42" is DELETE.
The book number (the path) stays the same; the request type (the method) decides what the librarian does. That is exactly how path operations work.
GETRead. Never changes data. Safe to repeat.POSTCreate something new. Repeating it creates another one.PUTReplace the whole thing with what you send. Repeating it gives the same result.PATCHChange only the fields you send.DELETERemove it. Repeating it does not remove more (the second time: 404).Example 1 - the to-do API
Here is the whole API. The data lives in a dict, TODOS, so it is lost when the server restarts - fine for learning. TodoIn describes what a client sends when it creates or replaces a to-do: a title (required) and done (optional, false by default). Lesson 1.6 explains these models properly; for now, read it as "the shape of the JSON body".
Notice the status codes. POST uses status_code=201, which means "Created". DELETE uses 204, "No Content" - success, with nothing to send back, so the function returns nothing. When a to-do does not exist, we raise HTTPException(404). Everything else answers 200 by default.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class TodoIn(BaseModel): # what the client sends (Lesson 1.6 explains this)
title: str
done: bool = False
TODOS = {1: {"id": 1, "title": "Buy milk", "done": False}} # our "database": a dict in memory
next_id = 2
@app.get("/todos") # READ all
def list_todos():
return list(TODOS.values())
@app.get("/todos/{todo_id}") # READ one
def get_todo(todo_id: int):
if todo_id not in TODOS:
raise HTTPException(status_code=404, detail="Todo not found")
return TODOS[todo_id]
@app.post("/todos", status_code=201) # CREATE
def create_todo(todo: TodoIn):
global next_id
TODOS[next_id] = {"id": next_id, **todo.model_dump()}
next_id += 1
return TODOS[next_id - 1]
@app.put("/todos/{todo_id}") # REPLACE
def replace_todo(todo_id: int, todo: TodoIn):
if todo_id not in TODOS:
raise HTTPException(status_code=404, detail="Todo not found")
TODOS[todo_id] = {"id": todo_id, **todo.model_dump()}
return TODOS[todo_id]
@app.delete("/todos/{todo_id}", status_code=204) # DELETE
def delete_todo(todo_id: int):
if TODOS.pop(todo_id, None) is None:
raise HTTPException(status_code=404, detail="Todo not found")Example 2 - test it with TestClient
TestClient sends real HTTP requests to your app from inside Python - no server, no second terminal. It is the fastest way to try many requests, and Module 9 uses it for automatic tests. client.request(method, url, json=...) sends a request; the response has .status_code and .text.
Read the output from top to bottom: list (200), create (201, id 2), read (200), replace (200, done: true), delete to-do 1 (204, empty body), read to-do 1 again (404), and the final list - only to-do 2 is left.
from fastapi.testclient import TestClient
from todos import app
client = TestClient(app) # sends real HTTP requests to the app, without a server
def show(method, url, **kw):
r = client.request(method, url, follow_redirects=False, **kw)
body = r.text if r.text else "(empty)"
extra = f" Allow: {r.headers['allow']}" if "allow" in r.headers else ""
extra += f" Location: {r.headers['location']}" if "location" in r.headers else ""
print(f"{method:6} {url:14} -> {r.status_code} {body[:90]}{extra}")
show("GET", "/todos")
show("POST", "/todos", json={"title": "Call the bank"})
show("GET", "/todos/2")
show("PUT", "/todos/2", json={"title": "Call the bank", "done": True})
show("DELETE", "/todos/1")
show("GET", "/todos/1")
show("GET", "/todos")
print("--- mistakes")
show("POST", "/todos/2", json={"title": "x"})
show("PATCH", "/todos/2", json={"done": False})
show("GET", "/todos/")
show("GET", "/Todos")
show("HEAD", "/todos")
show("POST", "/todos", json={"done": True})GET /todos -> 200 [{"id":1,"title":"Buy milk","done":false}]
POST /todos -> 201 {"id":2,"title":"Call the bank","done":false}
GET /todos/2 -> 200 {"id":2,"title":"Call the bank","done":false}
PUT /todos/2 -> 200 {"id":2,"title":"Call the bank","done":true}
DELETE /todos/1 -> 204 (empty)
GET /todos/1 -> 404 {"detail":"Todo not found"}
GET /todos -> 200 [{"id":2,"title":"Call the bank","done":true}]Watch out: With Starlette 1.7.0, importing TestClient printed a warning: "Using httpx with starlette.testclient is deprecated; install httpx2 instead." It still works. If you see it, the tools are moving to a new HTTP library - check the Starlette notes for your version.
The same API with curl
TestClient is for Python. From the terminal, use curl with a running server (fastapi dev todos.py). -X sets the method, -H adds a header, and -d sends a body. For JSON you must send the header Content-Type: application/json.
The last request shows what happens if you forget to send JSON: "title=Pay rent" is not JSON, so FastAPI answers 422 with "JSON decode error".
$ curl -X POST localhost:8120/todos -H "Content-Type: application/json" -d '{"title": "Pay rent"}'
{"id":2,"title":"Pay rent","done":false} [201]
$ curl localhost:8120/todos
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Pay rent","done":false}] [200]
$ curl -X DELETE localhost:8120/todos/2
[204]
$ curl -X POST localhost:8120/todos -H "Content-Type: application/json" -d 'title=Pay rent'
{"detail":[{"type":"json_invalid","loc":["body",0],"msg":"JSON decode error","input":{},"ctx":{"error":"Expecting value"}}]} [422]Status codes: which one when
The status code is the first thing a client checks, so choose it on purpose. FastAPI uses 200 unless you say otherwise; set status_code in the decorator for the success case, and raise HTTPException for errors.
Look at what /docs shows for our API (from openapi.json). The success codes we set are there - 201 for POST, 204 for DELETE - and 422 is added automatically wherever a request has input to check. But the 404 we raise inside the functions is NOT listed: FastAPI cannot see inside your code. Module 3 shows how to document it.
GET /todos List Todos -> ['200']
POST /todos Create Todo -> ['201', '422']
GET /todos/{todo_id} Get Todo -> ['200', '422']
PUT /todos/{todo_id} Replace Todo -> ['200', '422']
DELETE /todos/{todo_id} Delete Todo -> ['204', '422']200 OKSuccess, with data. The default.201 CreatedSomething new was created (POST).204 No ContentSuccess, nothing to send back (DELETE). The body must be empty.307 Temporary RedirectGo to another URL - FastAPI uses it for a trailing slash.404 Not FoundNo such path - or no such item (we raise it).405 Method Not AllowedThe path exists, but not with this method.422 Unprocessable EntityThe input failed the checks: wrong type, missing field, bad JSON.Mistakes, tested: 405, 307, 404 and 422
Wrong method. POST /todos/2 and PATCH /todos/2 both answered 405 Method Not Allowed: the path exists, but not with that method. The response has an Allow header that should list the methods you CAN use - but it said only "GET", although PUT and DELETE also work on that path. Each decorator is its own route, and only the first matching one is reported. Do not rely on the Allow header; look at /docs.
Trailing slash. GET /todos/ answered 307 with Location: .../todos - a redirect to the path without the slash. Browsers and curl -L follow it, but some clients do not follow redirects at all, and every redirect costs an extra round trip. Call the exact path.
Wrong case. GET /Todos answered 404: paths are case-sensitive. /todos and /Todos are different paths.
HEAD. HEAD /todos answered 405: in this version, a GET route does not answer HEAD requests automatically.
Missing field. POST /todos with {"done": true} answered 422: "title" is required by TodoIn, and the error says "Field required" at body -> title.
POST /todos/2 -> 405 {"detail":"Method Not Allowed"} Allow: GET
PATCH /todos/2 -> 405 {"detail":"Method Not Allowed"} Allow: GET
GET /todos/ -> 307 (empty) Location: http://testserver/todos
GET /Todos -> 404 {"detail":"Not Found"}
HEAD /todos -> 405 (empty) Allow: GET
POST /todos -> 422 {"detail":[{"type":"missing","loc":["body","title"],"msg":"Field required","input":{"done"Route order: the first match wins
FastAPI checks routes in the order you wrote them and uses the first one that matches. That matters when a fixed path and a path with a parameter look alike. We added GET /todos/latest AFTER GET /todos/{todo_id}. A request to /todos/latest matched /todos/{todo_id} first, tried to turn "latest" into an int, and answered 422. The latest() function never ran.
Fix: put fixed paths before paths with parameters. With /todos/latest first, it answered 200.
And a route defined twice - the same method and path - is not an error. Both were registered, the first one answered every request, and the second was dead code. Keep each path operation in one place.
wrong = FastAPI()
@wrong.get("/todos/{todo_id}")
def get_todo(todo_id: int):
return {"todo": todo_id}
@wrong.get("/todos/latest") # declared AFTER the {todo_id} route
def latest():
return {"todo": "the latest one"}
right = FastAPI()
@right.get("/todos/latest") # fixed paths FIRST
def latest2():
return {"todo": "the latest one"}
@right.get("/todos/{todo_id}")
def get_todo2(todo_id: int):
return {"todo": todo_id}
dup = FastAPI()
@dup.get("/hello")
def first():
return {"from": "first"}
@dup.get("/hello") # the same method and path again
def second():
return {"from": "second"}wrong order GET /todos/latest -> 422 {"detail":[{"type":"int_parsing","loc":["path","todo_id"],"msg":"Input should be a valid integer, unable to pa
right order GET /todos/latest -> 200 {"todo":"the latest one"}
duplicate GET /hello -> 200 {"from":"first"}
duplicate: routes registered for /hello: ['/hello', '/hello']PUT or PATCH?
PUT replaces the whole to-do: the client sends every field. If it leaves out done, done becomes the default (false) - not "unchanged". PATCH changes only the fields the client sends. That is often what a user wants ("just tick it as done"), and it is your exercise below.
The key to PATCH in FastAPI is model_dump(exclude_unset=True): it gives only the fields the client really sent. We tested the version without it: sending only {"done": true} also set the title to None - the title was wiped out.
Path operations at a glance
ReadDefault status 200.
@app.get("/todos")CreateSay it was created.
@app.post("/todos", status_code=201)ReplaceThe whole object.
@app.put("/todos/{todo_id}")Update partOnly sent fields.
@app.patch("/todos/{todo_id}")DeleteReturn nothing.
@app.delete("/todos/{todo_id}", status_code=204)Not foundAn error with a status.
raise HTTPException(status_code=404, detail="Todo not found")
Test quicklyNo server needed.
TestClient(app).post("/todos", json={...})Only sent fieldsFor PATCH.
changes.model_dump(exclude_unset=True)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Run fastapi dev todos.py, open /docs, and create, read and delete a to-do with "Try it out". Compare the status codes with the table above.”
“Add GET /todos/count after GET /todos/{todo_id}. What happens? Move it and try again.”
“Send the same POST twice. How many to-dos do you get? Now send the same PUT twice. What changes?”
“Run curl -i localhost:8000/todos/ and then curl -iL. What does -L change?”
What usually goes wrong
GET must only read. Browsers, caches and crawlers may repeat GET requests by themselves.
✗ @app.get("/todos/{todo_id}/delete")✓ @app.delete("/todos/{todo_id}", status_code=204)Tell the client what happened: 201 when created, 204 when deleted with no body.
✗ @app.post("/todos")✓ @app.post("/todos", status_code=201)/todos/latest answered 422 because /todos/{todo_id} came first. Put fixed paths first.
Sending only {"done": true} set the title to None in our test.
✗ TODOS[todo_id].update(changes.model_dump())✓ TODOS[todo_id].update(changes.model_dump(exclude_unset=True))Our 405 said "Allow: GET" although PUT and DELETE also existed on that path. Check /docs.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Add PATCH /todos/{todo_id} that changes only the fields the client sends. Sending {"done": true} must keep the title. Unknown ids must give 404.
Show hintHide hint
Make a second model where every field is optional (default None), and use model_dump(exclude_unset=True) to get only the sent fields.
Show solutionHide solution
from fastapi import HTTPException
from pydantic import BaseModel
from todos import TODOS, app # the to-do app from this lesson
class TodoPatch(BaseModel): # every field optional: send only what changes
title: str | None = None
done: bool | None = None
@app.patch("/todos/{todo_id}")
def update_todo(todo_id: int, changes: TodoPatch):
if todo_id not in TODOS:
raise HTTPException(status_code=404, detail="Todo not found")
TODOS[todo_id].update(changes.model_dump(exclude_unset=True)) # only the fields that were sent
return TODOS[todo_id]
# PATCH /todos/1 {"done": true} -> {'id': 1, 'title': 'Buy milk', 'done': True}
# PATCH /todos/1 {"title": "Buy oat milk"} -> {'id': 1, 'title': 'Buy oat milk', 'done': True}
# PATCH /todos/99 {"done": true} -> 404Add GET /todos/done that returns only the finished to-dos. Make sure it works even though GET /todos/{todo_id} exists.
Show hintHide hint
Put the fixed path before the path with the parameter.
Key points
- A path operation is a method plus a path, handled by one function.
- GET reads, POST creates, PUT replaces, PATCH updates part, DELETE removes.
- Set status_code for success (201, 204); raise HTTPException for errors (404).
- 405 means "this path, but not this method"; 404 means no such path or item; 422 means bad input.
- A trailing slash gives a 307 redirect; paths are case-sensitive.
- Routes are matched in order - fixed paths before {parameters}; a duplicate route is silently ignored.
- TestClient tests the API from Python with no server.
Quick check before you move on
Interview questions
Explain the HTTP methods used in a REST API and their semantics.
GET reads (safe, idempotent), POST creates or triggers (not idempotent), PUT replaces a resource (idempotent), PATCH partially updates, DELETE removes (idempotent). Status codes reflect the outcome: 200, 201 with the new resource, 204 for no content, 404 for missing resources.
How does FastAPI match routes, and what pitfall does that create?
Routes are checked in registration order and the first match wins. A parameterised path like /items/{id} declared before /items/me captures "me" - so fixed paths must come first.
How would you implement a partial update in FastAPI?
Use a model with all fields optional and apply model_dump(exclude_unset=True) to update only the fields the client sent, returning 404 if the resource does not exist.
Quiz
- 1.
What is the difference between PUT and PATCH?
- 2.
What did GET /todos/ (with a slash) return?
- 3.
Two functions use @app.get("/hello"). Which one runs?
- 4.
Why was 404 missing from the docs for GET /todos/{todo_id}?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...