Skip to main content
FastAPI · lesson 2 of 19
FastAPI beginner Lesson 2 of 19

Also 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

fastapi python http rest status-codes

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.

MethodConventional meaningTypical success code
GETRead a resource; change nothing200 OK
POSTCreate a new resource201 Created
PUTReplace a resource completely200 OK
PATCHChange some fields of a resource200 OK
DELETERemove a resource204 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

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

  1. Send the same POST to /v2/todos twice, then the same PUT to /todos/1 twice (in update.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.

Frequently Asked Questions

What status code does a FastAPI POST endpoint return by default?
200 OK, the same as every other route, even though the endpoint created something. FastAPI doesn't guess from the HTTP method. Pass status_code=201 (or status.HTTP_201_CREATED) to the @app.post decorator to return 201 Created, which tells clients a new resource exists.
What is the difference between PUT and PATCH in FastAPI?
By convention PUT replaces a whole resource, so its request model has every field required, and a body missing a field is rejected with 422. PATCH changes only the fields sent, so its model makes every field optional and the route applies model_dump(exclude_unset=True) to update just those keys. FastAPI enforces neither meaning; your models and route code do.
How do I return 204 No Content from a FastAPI DELETE route?
Declare status_code=204 on the decorator, usually as status.HTTP_204_NO_CONTENT, and return nothing from the function. FastAPI then sends an empty body. A 204 response must not carry a body, so don't return a dict from that route.
Why does my FastAPI endpoint return 500 Internal Server Error?
A 500 means an exception escaped your route function, such as a KeyError from looking up a missing dictionary key. The client only sees the plain-text body 'Internal Server Error'; the traceback is printed in the Uvicorn terminal, where the last frame points at the line in your file that failed. Check for missing data yourself and raise HTTPException(status_code=404) instead.
What does idempotent mean for HTTP methods?
A method is idempotent if sending the same request twice leaves the server in the same state as sending it once. GET, PUT and DELETE are idempotent by convention, so clients may retry them safely after a timeout. POST is not: two identical POSTs to a create endpoint create two resources with different ids.