If you have been following this FastAPI series, we have built CRUD operations, connected to a database, and secured our endpoints with JWTs. But what happens when things go wrong?
When a user searches for an item that doesn't exist, or tries to access a route without a token, your API needs to communicate the exact problem. Returning a generic 500 Internal Server Error or a standard 200 OK with an error message in the body is a bad practice.
Today, we are going to look at the most common HTTP status codes and how to raise them cleanly using FastAPI's HTTPException.
The Core Status Codes You Need to Know
HTTP status codes are grouped by their starting number.
2xx (Success): Everything worked perfectly.
4xx (Client Error): The user did something wrong (bad data, missing token, wrong URL).
5xx (Server Error): Your code broke.
Here are the four most critical 4xx errors you will use in FastAPI:
1. 404 Not Found
Use this when a requested resource does not exist in your database.
from fastapi import FastAPI, HTTPException
app = FastAPI()
fake_db = {"1": "Apple", "2": "Banana"}
@app.get("/items/{item_id}")
def get_item(item_id: str):
if item_id not in fake_db:
raise HTTPException(
status_code=404,
detail=f"Item with ID {item_id} does not exist."
)
return {"item": fake_db[item_id]}
2. 400 Bad Request
Use this when the user sends a request that is syntactically correct but fails a business logic rule.
Note: If you use Pydantic models (like we covered in Part 4), FastAPI automatically handles basic validation errors (like ensuring an amount is a positive number) and throws a 422 Unprocessable Entity. You should reserve 400 for business rules your schema can't easily express, like checking an account balance.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
# Mock database of account balances
account_balances = {"account_abc": 100}
class TransferRequest(BaseModel):
account_id: str
# Pydantic handles the > 0 check automatically, returning a 422 if it fails
amount: int = Field(gt=0)
@app.post("/transfer")
def transfer_funds(request: TransferRequest):
current_balance = account_balances.get(request.account_id, 0)
# We use a 400 here for business logic that Pydantic cannot know about
if request.amount > current_balance:
raise HTTPException(
status_code=400,
detail="Insufficient funds for this transfer."
)
account_balances[request.account_id] -= request.amount
return {
"message": "Transfer successful",
"remaining_balance": account_balances[request.account_id]
}
This is the 400 error, when we request an amount greater than what is present in our account balance.
This is 422 error, when we add integers where strings should be there.
3. 401 Unauthorized (Authentication)
This is the error you throw when someone tries to access a protected route without a JWT token, or with an expired/invalid token. Think of this as: "I don't know who you are."
from fastapi import Header
@app.get("/profile")
def get_profile(authorization: str = Header(default=None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Missing or invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return {"user": "ZeroShotAnu"}
4. 403 Forbidden (Authorization)
This is often confused with 401. You use 403 Forbidden when you know who the user is (their token is valid), but they do not have the permission to do what they are trying to do. Think of this as: "I know who you are, but you aren't an Admin."
@app.delete("/users/{user_id}")
def delete_user(user_id: str, current_user_role: str = "guest"):
# The user is logged in, but they are just a guest
if current_user_role != "admin":
raise HTTPException(
status_code=403,
detail="You do not have permission to delete users."
)
return {"message": "User deleted"}
Another example I tried here is, we already have this user existing and hence it is forbidden to create the same user again.
Error
So when I create a real new student,
it shows a success response:
🛡️ Security Pro-Tip: The 404 Fallback
If a user tries to access a specific private resource (like someone else's billing record), returning a 403 confirms that the record actually exists. To prevent attackers from guessing valid IDs in your database, highly secure APIs will often return a 404 Not Found instead, keeping the attacker entirely in the dark.
fake_db = {"invoice_88": {"owner": "Alice", "amount": 100}}
@app.get("/invoices/{invoice_id}")
def get_private_invoice(invoice_id: str, current_user: str = "Bob"):
invoice = fake_db.get(invoice_id)
# Hide both "doesn't exist" and "unauthorized" behind the same 404 error
if not invoice or invoice["owner"] != current_user:
raise HTTPException(
status_code=404,
detail="Invoice not found."
)
return invoice
Why This Matters
Proper status codes make your API predictable. When a frontend developer (or an AI Agent) interacts with your API, they don't read the text in your detail message first; their code looks at the status_code to decide what to do next.
If they see a 401, they redirect the user to the login screen.
If they see a 404, they show a "Page Not Found" illustration.
The next post will be the last post in this series, we will learn about APIRouter and Background tasks. Hope you enjoyed this series.
Do ask any questions you have in the comments below.













Top comments (1)
One thing worth testing in the
/transferexample: withdef transfer_funds(amount: int)as a bare parameter, FastAPI treatsamountas a query parameter, not a JSON field. I ran it with the test client:POST /transfer?amount=5returns 200,POST /transferwith body{"amount": 5}returns 422 (missing query param),?amount=abcreturns 422, and?amount=-1reaches your 400. So a client sending JSON, which is what most people expect from a money endpoint, never gets to the business rule.The usual fix is a Pydantic body model with
amount: int = Field(gt=0), which turns the<= 0check into a 422 by itself and leaves 400 for rules the schema can't express (insufficient balance, say; many APIs use 409 or 422 there).When you cover 403 next: for resources a user may not see, many APIs return 404 instead, so the status code doesn't confirm that an ID exists. Do you plan to cover that choice in this series?