FastAPI · lesson 2 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6Also part of: Networking
FastAPI HTTP Methods and Status Codes
GET, POST, PUT, PATCH and DELETE on an in-memory FastAPI store, with real curl output for 200, 201, 204, 404, 405, 422 and an unhandled 500.
By SkillByExample · Published
Methods Say What, Status Codes Say How It Went
Every HTTP request carries a method, a verb that says what the client wants done with the URL. Every response starts with a status code, a three-digit number that says how it went. The first digit is the category: 2xx success, 3xx look elsewhere, 4xx the client got something wrong, 5xx the server broke.
| Method | Conventional meaning | Typical success code |
|---|---|---|
GET | Read a resource; change nothing | 200 OK |
POST | Create a new resource | 201 Created |
PUT | Replace a resource completely | 200 OK |
PATCH | Change some fields of a resource | 200 OK |
DELETE | Remove a resource | 204 No Content |
FastAPI has one decorator per method: @app.get, @app.post, @app.put, @app.patch, @app.delete. The examples below each build a small to-do API on a Python dict, so restarting the server resets the data. Outputs come from FastAPI 0.143.0 and Uvicorn 0.54.0 on Python 3.12; run each file with uvicorn <file>:app.
GET: Read, and 404 When It Isn’t There
Save as read.py:
from fastapi import FastAPI, HTTPException
app = FastAPI()
TODOS = {
1: {"id": 1, "title": "Buy milk", "done": False},
2: {"id": 2, "title": "Write report", "done": True},
}
@app.get("/todos")
def list_todos():
return list(TODOS.values())
@app.get("/todos/{todo_id}")
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]
$ curl -i http://127.0.0.1:8000/todos
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:54:13 GMT
server: uvicorn
content-length: 86
content-type: application/json
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Write report","done":true}]
$ curl -i http://127.0.0.1:8000/todos/2
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:54:13 GMT
server: uvicorn
content-length: 43
content-type: application/json
{"id":2,"title":"Write report","done":true}
$ curl -i http://127.0.0.1:8000/todos/7
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 06:54:13 GMT
server: uvicorn
content-length: 27
content-type: application/json
{"detail":"Todo not found"}
{todo_id} in the path is a path parameter: whatever appears in that position is passed to the function, converted to int because of the type hint. The next lesson covers path parameters in depth. curl sends GET unless told otherwise.
Note the difference from the first lesson’s 404. There, no route matched. Here the route /todos/{todo_id} matched and ran, and your code decided the to-do didn’t exist. raise HTTPException(...) stops the function and sends that status code, with detail as the JSON body.
POST: Create, and Ask for 201
Creating needs a request body, the JSON the client sends. FastAPI reads it into a Pydantic model, a class that lists the expected fields (covered fully in Request Bodies and Response Models). Save as create.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
TODOS = {}
class NewTodo(BaseModel):
title: str
@app.post("/todos")
def create_todo(todo: NewTodo):
todo_id = len(TODOS) + 1
TODOS[todo_id] = {"id": todo_id, "title": todo.title, "done": False}
return TODOS[todo_id]
@app.post("/v2/todos", status_code=201)
def create_todo_v2(todo: NewTodo):
todo_id = len(TODOS) + 1
TODOS[todo_id] = {"id": todo_id, "title": todo.title, "done": False}
return TODOS[todo_id]
-X POST sets the method, -H adds a header announcing JSON, and -d is the body:
$ curl -i -X POST http://127.0.0.1:8000/todos -H 'content-type: application/json' -d '{"title": "Buy milk"}'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:54:14 GMT
server: uvicorn
content-length: 40
content-type: application/json
{"id":1,"title":"Buy milk","done":false}
$ curl -i -X POST http://127.0.0.1:8000/v2/todos -H 'content-type: application/json' -d '{"title": "Walk dog"}'
HTTP/1.1 201 Created
date: Tue, 13 Oct 2026 06:54:14 GMT
server: uvicorn
content-length: 40
content-type: application/json
{"id":2,"title":"Walk dog","done":false}
The two routes are identical except for status_code=201, and the first one answers 200 OK even though it created a to-do. FastAPI doesn’t pick a status code from the method: every route returns 200 unless you say otherwise. 201 Created is the conventional answer to a successful create, and well-behaved clients and API tools check for it.
PUT Replaces, PATCH Changes
PUT sends the whole resource, PATCH only the fields that change. The models make the difference: Todo requires both fields, and TodoPatch makes both optional. Save as update.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
TODOS = {1: {"id": 1, "title": "Buy milk", "done": False}}
class Todo(BaseModel):
title: str
done: bool
class TodoPatch(BaseModel):
title: str | None = None
done: bool | None = None
@app.put("/todos/{todo_id}")
def replace_todo(todo_id: int, todo: Todo):
TODOS[todo_id] = {"id": todo_id, **todo.model_dump()}
return TODOS[todo_id]
@app.patch("/todos/{todo_id}")
def update_todo(todo_id: int, changes: TodoPatch):
TODOS[todo_id].update(changes.model_dump(exclude_unset=True))
return TODOS[todo_id]
$ curl -i -X PATCH http://127.0.0.1:8000/todos/1 -H 'content-type: application/json' -d '{"done": true}'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:54:21 GMT
server: uvicorn
content-length: 39
content-type: application/json
{"id":1,"title":"Buy milk","done":true}
$ curl -i -X PUT http://127.0.0.1:8000/todos/1 -H 'content-type: application/json' -d '{"title": "Buy oat milk"}'
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 06:54:21 GMT
server: uvicorn
content-length: 109
content-type: application/json
{"detail":[{"type":"missing","loc":["body","done"],"msg":"Field required","input":{"title":"Buy oat milk"}}]}
$ curl -i -X PUT http://127.0.0.1:8000/todos/1 -H 'content-type: application/json' -d '{"title": "Buy oat milk", "done": false}'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:54:21 GMT
server: uvicorn
content-length: 44
content-type: application/json
{"id":1,"title":"Buy oat milk","done":false}
The PATCH sent only done, and the title survived. exclude_unset=True is what makes that work: it dumps only the fields the client actually sent, so title isn’t overwritten with None.
The first PUT sent only a title and got 422 Unprocessable Entity. Your function never ran: FastAPI checked the body against Todo, found done missing, and answered for you. The body lists every problem, with loc saying where (body → done). Path and Query Parameters and Request Bodies and Response Models dig into this format.
When Your Code Crashes: 500
The update_todo route above has a bug. PATCH a to-do that doesn’t exist:
$ curl -i -X PATCH http://127.0.0.1:8000/todos/99 -H 'content-type: application/json' -d '{"done": true}'
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 06:54:21 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8
Internal Server Error
TODOS[99] raised KeyError, nothing caught it, and the client got 500 Internal Server Error. Notice the content type is text/plain, not JSON: the error escaped FastAPI’s handling completely. The client learns nothing, which is deliberate, since a traceback can leak file paths and code. The details go to the server’s terminal. Here are the first and last lines of what Uvicorn printed, with about 30 frames from inside uvicorn, starlette, fastapi and anyio trimmed from the middle:
INFO: 127.0.0.1:49208 - "PATCH /todos/99 HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\tmp\fapi-b1\venv\Lib\site-packages\uvicorn\protocols\http\h11_impl.py", line 411, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\tmp\fapi-b1\l2\update.py", line 27, in update_todo
TODOS[todo_id].update(changes.model_dump(exclude_unset=True))
~~~~~^^^^^^^^^
KeyError: 99
Read a traceback from the bottom: KeyError: 99 on line 27 of update.py, the TODOS[todo_id] lookup. A 500 always means a bug on the server side. The fix is to check first and raise a 404, as read.py does; that’s the first practice task.
DELETE: 204 and an Empty Body
Save as delete.py:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
TODOS = {1: {"id": 1, "title": "Buy milk", "done": False}}
@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int):
if todo_id not in TODOS:
raise HTTPException(status_code=404, detail="Todo not found")
del TODOS[todo_id]
$ curl -i -X DELETE http://127.0.0.1:8000/todos/1
HTTP/1.1 204 No Content
date: Tue, 13 Oct 2026 06:54:22 GMT
server: uvicorn
content-type: application/json
$ curl -i -X DELETE http://127.0.0.1:8000/todos/1
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 06:54:22 GMT
server: uvicorn
content-length: 27
content-type: application/json
{"detail":"Todo not found"}
204 No Content means “done, and there’s nothing to send back”. The function returns nothing and the response has no body, not even null. It still carries a content-type: application/json header, which is harmless with no body. The status module holds named constants like status.HTTP_204_NO_CONTENT, which read better than bare numbers. They are plain integers.
The second DELETE got 404 because the first one worked. Deleting is still called idempotent: repeating it leaves the server in the same state (the to-do is gone), even though the status code differs.
Practice
- Fix the 500 in
update.py: PATCH on a missing to-do should return 404 with{"detail":"Todo not found"}.
Solution
@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))
return TODOS[todo_id]
Import HTTPException from fastapi as well.
$ curl -i -X PATCH http://127.0.0.1:8000/todos/99 -H 'content-type: application/json' -d '{"done": true}'
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 06:54:39 GMT
server: uvicorn
content-length: 27
content-type: application/json
{"detail":"Todo not found"}
- Add
DELETE /todos(no id) that removes every to-do and returns 204. Then PATCH to-do 1 and predict the status.
Solution
@app.delete("/todos", status_code=status.HTTP_204_NO_CONTENT)
def clear_todos():
TODOS.clear()
$ curl -i -X DELETE http://127.0.0.1:8000/todos
HTTP/1.1 204 No Content
date: Tue, 13 Oct 2026 06:54:39 GMT
server: uvicorn
content-type: application/json
$ curl -i -X PATCH http://127.0.0.1:8000/todos/1 -H 'content-type: application/json' -d '{"done": true}'
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 06:54:39 GMT
server: uvicorn
content-length: 27
content-type: application/json
{"detail":"Todo not found"}
/todos and /todos/{todo_id} are separate routes, so the DELETE with no id runs clear_todos. With the 404 fix from task 1 in place, the PATCH gets 404 instead of 500.
- Send the same POST to
/v2/todostwice, then the same PUT to/todos/1twice (inupdate.py). Which method gives a different result the second time?
Solution
$ curl -s -X POST http://127.0.0.1:8000/v2/todos -H 'content-type: application/json' -d '{"title": "Buy milk"}'
{"id":1,"title":"Buy milk","done":false}
$ curl -s -X POST http://127.0.0.1:8000/v2/todos -H 'content-type: application/json' -d '{"title": "Buy milk"}'
{"id":2,"title":"Buy milk","done":false}
$ curl -s -X PUT http://127.0.0.1:8000/todos/1 -H 'content-type: application/json' -d '{"title": "Buy milk", "done": true}'
{"id":1,"title":"Buy milk","done":true}
$ curl -s -X PUT http://127.0.0.1:8000/todos/1 -H 'content-type: application/json' -d '{"title": "Buy milk", "done": true}'
{"id":1,"title":"Buy milk","done":true}
POST created two to-dos; PUT set the same to-do to the same value twice. That’s why PUT is idempotent and POST isn’t, and why a client may safely retry a PUT after a timeout but not a POST.