← Back to FastAPI
Lesson 1.3 · Getting Started

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.

Beginner35 min

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.

workflowOne path, many operationsstep 1 / 4

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.

body sent
{"title": "Call the bank"}
status
201 Created
new id
2
done
false (default)

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.

Small dictionary
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.

The five methods
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.

Example 1 - todos.py
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.

Example 2 - try_todos.py
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})
Output - the normal requests
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".

Output - curl against fastapi dev todos.py --port 8120
$ 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.

Output - what the docs list for each path operation
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']
The status codes in this lesson
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.

Output - the mistakes (from try_todos.py)
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.

Example 3 - order.py (shortened: the three apps)
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"}
Output
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

Read

Default status 200.

@app.get("/todos")
Create

Say it was created.

@app.post("/todos", status_code=201)
Replace

The whole object.

@app.put("/todos/{todo_id}")
Update part

Only sent fields.

@app.patch("/todos/{todo_id}")
Delete

Return nothing.

@app.delete("/todos/{todo_id}", status_code=204)
Not found

An error with a status.

raise HTTPException(status_code=404, detail="Todo not found")
Test quickly

No server needed.

TestClient(app).post("/todos", json={...})
Only sent fields

For 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.

In the docs

“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.”

Wrong order

“Add GET /todos/count after GET /todos/{todo_id}. What happens? Move it and try again.”

Repeat a POST

“Send the same POST twice. How many to-dos do you get? Now send the same PUT twice. What changes?”

Trailing slash

“Run curl -i localhost:8000/todos/ and then curl -iL. What does -L change?”

What usually goes wrong

Using GET to change data

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)
Returning 200 for everything

Tell the client what happened: 201 when created, 204 when deleted with no body.

✗ @app.post("/todos")
✓ @app.post("/todos", status_code=201)
A parameter route before a fixed route

/todos/latest answered 422 because /todos/{todo_id} came first. Put fixed paths first.

PATCH without exclude_unset

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))
Trusting the Allow header

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.

1.

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 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 solution
One solution - tested
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} -> 404
2.

Add GET /todos/done that returns only the finished to-dos. Make sure it works even though GET /todos/{todo_id} exists.

Show 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

What is a path operation?
A method plus a path, like POST /todos, handled by one function.
Which status code for a successful DELETE with no body?
204 No Content.
Why did GET /todos/latest answer 422?
It matched GET /todos/{todo_id} first, and "latest" is not an int.
What does 405 mean?
The path exists, but not with that method.

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. 1.

    What is the difference between PUT and PATCH?

  2. 2.

    What did GET /todos/ (with a slash) return?

  3. 3.

    Two functions use @app.get("/hello"). Which one runs?

  4. 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...