Status Codes
Every response starts with a three-digit number that tells the client what happened. Learn the few codes you need every day - 200, 201, 204, 400, 404, 409, 422 - set them with status_code= or the Response object, and see which ones FastAPI already sends for you.
What you will be able to do
- Read a status code and know who must act: the client or the server
- Choose the right code for create, read, update and delete
- Set a fixed code with status_code= and the status constants
- Change the code inside a function with the Response object
- Know the codes FastAPI sends by itself: 404, 405 and 422
- Show extra codes like 404 in /docs with responses=
The idea, in plain English
When a client calls your API, the first thing it reads is not your JSON - it is a number like 200 or 404. This status code tells it what happened, before it looks at the body. Programs depend on it: a frontend shows "Saved!" on 201 and an error box on 422; a retry tool tries again on 503 but not on 400.
So far our endpoints mostly answered 200. In this lesson we give each to-do action the code that fits it, and break things on purpose to see which codes FastAPI already sends for us.
FastAPI 0.143.0, tested with TestClient. Module 3 is about everything that goes back to the client: codes now, errors in 3.2, then other response types, headers and cookies, forms and files.
Worked example: A to-do API where every action answers with the right code - created, updated, deleted, not found, already done - tested one by one.
2xx - it worked
Different success codes say different things: read, created, or done with nothing to send back.
The first digit of a status code says who has to do something next. Real responses from our to-do API.
Words you will see in this lesson
A few words about status codes.
Status codeThe three-digit number at the start of every response.Reason phraseThe short text after the number: "Not Found", "Created".Success (2xx)The request worked.Client error (4xx)The request was wrong; the client must change it.Server error (5xx)The server failed; not the client’s fault.statusA FastAPI module with named constants: status.HTTP_201_CREATED.An everyday example: a restaurant order
You order at a restaurant. The waiter can say: "Here is your food" (200). "Your order is placed, number 42" (201). "Done, nothing more to bring" (204). "We have no dish with that name" (404). "Your table already ordered dessert" (409). "Please write a number, not ‘lots’" (422). Or, from the kitchen: "Sorry, the oven broke" (500).
In every case you know at once what to do next - wait, fix your order, or come back later. Status codes give the client the same clear signal.
The codes you need every day
There are many status codes, but a small set covers most APIs. Learn these, and look up the rest when you meet them.
200 OKWorked; the body has the result. The default in FastAPI.201 CreatedSomething new was created (POST, or PUT to a new id).204 No ContentWorked; nothing to send back (DELETE).400 Bad RequestThe request breaks a business rule (not enough money).401 UnauthorizedNot logged in (Module 6).403 ForbiddenLogged in, but not allowed.404 Not FoundNo such thing - wrong id or wrong path.405 Method Not AllowedThe path exists, but not with this method. Sent by FastAPI.409 ConflictClashes with the current state (already done, email taken).422 Unprocessable ContentThe data has the wrong shape or type. Sent by FastAPI.500 Internal Server ErrorA bug in our code.Example 1 - a to-do API with the right codes
read_todo has no status_code, so a success is 200. When the id is unknown, it raises HTTPException(status_code=404) - Lesson 3.2 covers errors in detail.
create_todo sets status_code=status.HTTP_201_CREATED in the decorator. status.HTTP_201_CREATED is simply the number 201 with a name - easier to read, and your editor can complete it. delete_todo uses 204 and returns nothing.
put_todo cannot choose its code in advance: replacing an existing to-do is 200, creating a new one is 201. So it takes a response: Response parameter and sets response.status_code inside the function. FastAPI still turns the return value into JSON.
finish uses 409 Conflict: the request is fine, but it clashes with the current state - the to-do is already done.
from fastapi import FastAPI, HTTPException, Response, status
from pydantic import BaseModel
app = FastAPI()
TODOS = {1: {"id": 1, "title": "Buy milk", "done": False}}
class TodoIn(BaseModel):
title: str
done: bool = False
@app.get("/todos/{todo_id}") # 200 is the default
def read_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=status.HTTP_201_CREATED) # created something new
def create_todo(todo: TodoIn):
new_id = max(TODOS) + 1
TODOS[new_id] = {"id": new_id, **todo.model_dump()}
return TODOS[new_id]
@app.put("/todos/{todo_id}") # 200 if replaced, 201 if created
def put_todo(todo_id: int, todo: TodoIn, response: Response):
if todo_id not in TODOS:
response.status_code = status.HTTP_201_CREATED
TODOS[todo_id] = {"id": todo_id, **todo.model_dump()}
return TODOS[todo_id]
@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) # done, nothing to send
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]
@app.delete("/oops/{todo_id}", status_code=204) # 204 but returns a body
def delete_with_body(todo_id: int):
return {"deleted": todo_id}
@app.post("/todos/{todo_id}/done")
def finish(todo_id: int):
if todo_id not in TODOS:
raise HTTPException(status_code=404, detail="Todo not found")
if TODOS[todo_id]["done"]:
raise HTTPException(status_code=409, detail="Todo is already done") # conflict with current state
TODOS[todo_id]["done"] = True
return TODOS[todo_id]from fastapi.testclient import TestClient
from status import app
c = TestClient(app)
for label, method, url, body in [
("read", "GET", "/todos/1", None),
("read missing", "GET", "/todos/99", None),
("read bad id", "GET", "/todos/abc", None),
("create", "POST", "/todos", {"title": "Call mom"}),
("create no title", "POST", "/todos", {}),
("put existing", "PUT", "/todos/1", {"title": "Buy oat milk"}),
("put new", "PUT", "/todos/7", {"title": "Water plants"}),
("finish", "POST", "/todos/2/done", None),
("finish again", "POST", "/todos/2/done", None),
("delete", "DELETE", "/todos/2", None),
("delete again", "DELETE", "/todos/2", None),
("204 with body", "DELETE", "/oops/1", None),
("wrong method", "PATCH", "/todos/1", None),
("unknown path", "GET", "/tasks", None),
]:
r = c.request(method, url, json=body)
print(f"{label:16} {method:6} {url:15} -> {r.status_code} {r.reason_phrase:22} {r.text[:110]!r}")The output, line by line
Every action got its own code. "put existing" was 200 and "put new" was 201 - the same endpoint, two codes, chosen by response.status_code. "delete" was 204 with an empty body, and deleting the same to-do again was 404.
Four codes came from FastAPI itself, without one line of our code. 422 for an id that is not a number and for a missing title (Module 2). 405 for PATCH, because /todos/{todo_id} exists but has no PATCH. 404 "Not Found" for /tasks, a path that does not exist at all. Compare that detail with our own 404: "Todo not found". The client can tell a wrong path from a missing to-do.
read GET /todos/1 -> 200 OK '{"id":1,"title":"Buy milk","done":false}'
read missing GET /todos/99 -> 404 Not Found '{"detail":"Todo not found"}'
read bad id GET /todos/abc -> 422 Unprocessable Entity '{"detail":[{"type":"int_parsing","loc":["path","todo_id"],"msg":"Input should be a valid integer, unable to pa'
create POST /todos -> 201 Created '{"id":2,"title":"Call mom","done":false}'
create no title POST /todos -> 422 Unprocessable Entity '{"detail":[{"type":"missing","loc":["body","title"],"msg":"Field required","input":{}}]}'
put existing PUT /todos/1 -> 200 OK '{"id":1,"title":"Buy oat milk","done":false}'
put new PUT /todos/7 -> 201 Created '{"id":7,"title":"Water plants","done":false}'
finish POST /todos/2/done -> 200 OK '{"id":2,"title":"Call mom","done":true}'
finish again POST /todos/2/done -> 409 Conflict '{"detail":"Todo is already done"}'
delete DELETE /todos/2 -> 204 No Content ''
delete again DELETE /todos/2 -> 404 Not Found '{"detail":"Todo not found"}'
204 with body DELETE /oops/1 -> 204 No Content ''
wrong method PATCH /todos/1 -> 405 Method Not Allowed '{"detail":"Method Not Allowed"}'
unknown path GET /tasks -> 404 Not Found '{"detail":"Not Found"}'Tip: The reason phrase for 422 printed as "Unprocessable Entity" - its older name. The newer name is "Unprocessable Content", and FastAPI’s constant is status.HTTP_422_UNPROCESSABLE_CONTENT. The number, 422, is what programs check.
204 means no body - even if you return one
delete_with_body sets status_code=204 but returns {"deleted": 1}. The client received 204 and an empty body: our dictionary was silently thrown away. That is correct HTTP - a 204 response has no body - but it surprises people.
If the client needs to see something after a delete (for example, what was deleted), use 200 and return it. If not, use 204 and return nothing.
Which code? Ask three questions
Did it work? Then 2xx: 201 if something new now exists, 204 if there is nothing to send, otherwise 200.
Did it fail because of the request? Then 4xx. Wrong id: 404. Wrong data shape: 422 (FastAPI does it). Clashes with the current state: 409. Breaks a business rule: 400. Not logged in or not allowed: 401 or 403.
Did it fail because of us? Then 5xx - and you normally do not send it on purpose. It is what happens when your code crashes (Lesson 3.2).
Codes in /docs
FastAPI writes the main code of every endpoint into the docs - 201 for create_todo, 204 for delete_todo - plus 422, because every endpoint with inputs can return it. But it cannot see inside your function: the 404 from HTTPException and the 201 from response.status_code are not listed.
To show them, add responses= to the decorator. Each entry is a code with a description, and optionally a model for the body. We tried it on a second version of read_todo: the docs now list 200, 404 and 422.
class Message(BaseModel):
detail: str
@app.get("/v2/todos/{todo_id}", responses={404: {"model": Message, "description": "No todo with this id"}})
def read_todo_v2(todo_id: int):
...GET /todos/{todo_id} -> ['200', '422']
PUT /todos/{todo_id} -> ['200', '422']
DELETE /todos/{todo_id} -> ['204', '422']
POST /todos -> ['201', '422']
DELETE /oops/{todo_id} -> ['204', '422']
POST /todos/{todo_id}/done -> ['200', '422']
GET /v2/todos/{todo_id} -> ['200', '404', '422']Status codes at a glance
Fixed codeIn the decorator.
@app.post("/todos", status_code=201)Named constantSame number, easier to read.
status.HTTP_201_CREATED
Choose at run timeResponse parameter.
def put(..., response: Response):
response.status_code = 201No bodyReturn nothing.
@app.delete("/todos/{id}", status_code=204)Error codeStops the function.
raise HTTPException(status_code=404, detail="...")
Show in /docsExtra codes.
responses={404: {"model": Message}}Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Add PATCH /todos/{todo_id} that changes only the title. Which code should it return, and what happens to the 405?”
“Make create_todo answer 400 with "Title cannot be empty" when the title is only spaces. Is 400 or 422 a better choice?”
“Open /docs and compare the codes listed for PUT and POST. Add responses= so PUT shows 201 too.”
“In a Python shell, print status.HTTP_404_NOT_FOUND and status.HTTP_409_CONFLICT.”
What usually goes wrong
A client cannot tell "created" from "read", and some tools only check the code.
✗ @app.post("/todos")✓ @app.post("/todos", status_code=201)Returning {"error": "not found"} with 200 tells the client it worked. Raise HTTPException(404) instead.
✗ return {"error": "Todo not found"}✓ raise HTTPException(status_code=404, detail="Todo not found")The body was silently dropped. Use 200 if the client needs it.
401: we do not know who you are. 403: we know you, and you may not do this.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Add POST /todos/{todo_id}/undo: 404 if the to-do does not exist, 409 if it is not done yet, otherwise mark it not done and return it with 200. Write a test that checks all three codes.
Show hintHide hint
Copy finish and swap the check. In the test, assert r.status_code == 409 after the first undo.
Key points
- The first digit says who must act: 2xx worked, 4xx the client must change something, 5xx our problem.
- Use 201 for created, 204 for nothing to send, 200 for the rest of success.
- Set a fixed code with status_code= (status constants are easier to read).
- Choose the code inside the function with a Response parameter.
- FastAPI sends 404 for unknown paths, 405 for wrong methods and 422 for bad data by itself.
- A 204 response has no body; codes raised inside the function need responses= to appear in /docs.
Quick check before you move on
Interview questions
When would you return 201, 204 or 200?
201 when a resource was created (often with the new resource or a Location header), 204 when the action succeeded and there is no body (typical for DELETE), 200 for other successful responses with a body.
What is the difference between 400, 409 and 422?
422: the request is well-formed but fails schema validation (FastAPI default). 409: it conflicts with the current resource state (duplicate, already processed). 400: a generic client error, often a broken business rule.
How do you set the status code dynamically in FastAPI?
Declare a Response parameter and set response.status_code, or return a Response/JSONResponse with status_code; the decorator status_code is only the default.
Quiz
- 1.
A to-do is already done and the client finishes it again. Which code did we use, and why?
- 2.
How can a client tell a wrong path from a missing to-do?
- 3.
Why did /docs not show 404 for read_todo?
- 4.
Who must act on a 5xx?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...