Error Handling with HTTPException¶
When something goes wrong, you want to send a proper HTTP error back to the client — not crash or return a 500.
The classic pattern¶
from fastapi import FastAPI, HTTPException
app = FastAPI()
users = {1: "Alice", 2: "Bob", 3: "Charlie"}
@app.get("/users/{user_id}")
def get_user(user_id: int):
if user_id not in users:
raise HTTPException(status_code=404, detail="User not found")
return {"id": user_id, "name": users[user_id]}
Hit GET /users/999:
Status: 404 Not Found. Status: 4xx means client's fault — they asked for something that doesn't exist.
How it executes¶
When you raise HTTPException(404, ...):
- Python stops executing your function.
- FastAPI catches the exception (it knows how to handle this type).
- It builds an HTTP response with the given status code.
- The
detailbecomes the JSON body. - uvicorn sends the response.
You never have to return early or check error flags — just raise.
Use the status constants¶
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
Better than magic numbers.
Custom error detail — any JSON¶
detail can be a string OR a dict OR a list:
raise HTTPException(
status_code=400,
detail={
"error": "validation_failed",
"fields": ["email", "phone"],
"hint": "email must be a valid address"
}
)
Custom headers¶
raise HTTPException(
status_code=401,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
When to use which status code¶
| Situation | Status | When |
|---|---|---|
| Wrong path/missing resource | 404 | GET /users/999 and user 999 doesn't exist |
| Wrong input shape (caught automatically by Pydantic) | 422 | Field missing, wrong type |
| Logical input error (caught manually) | 400 | "Email already registered" |
| Need to log in | 401 | No auth token |
| Logged in but not allowed | 403 | Tried to delete another user |
| Conflict | 409 | Username already taken |
| Server bug | 500 | Unhandled exception |
| Slow / overloaded | 503 | Server busy |
Practical patterns¶
Validate before doing work¶
from fastapi import HTTPException, status
@app.post("/users")
def create_user(user: User):
if email_exists(user.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"email '{user.email}' already registered",
)
if not is_email_valid(user.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid email format",
)
return save_user(user)
Guard with a tiny helper¶
def get_or_404(model_id: int):
record = DB.get(model_id)
if record is None:
raise HTTPException(404, detail=f"id {model_id} not found")
return record
@app.get("/users/{user_id}")
def get_user(user_id: int):
return get_or_404(user_id)
@app.delete("/users/{user_id}", status_code=204)
def delete_user(user_id: int):
get_or_404(user_id)
DB.pop(user_id)
return None
DRY — no duplicated 404 logic.
Custom exception handlers¶
For app-wide consistent error responses, register a handler:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class BusinessRuleError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
@app.exception_handler(BusinessRuleError)
async def biz_error_handler(req: Request, exc: BusinessRuleError):
return JSONResponse(
status_code=400,
content={"error_code": exc.code, "error_message": exc.message},
)
@app.post("/transfer")
def transfer(amount: int):
if amount > 100_000:
raise BusinessRuleError("AMOUNT_TOO_HIGH", "exceeds daily limit")
return {"transferred": amount}
Override Pydantic's default validation error¶
By default a Pydantic error returns a verbose 422. You can re-shape it:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(req: Request, exc: RequestValidationError):
return JSONResponse(
status_code=400,
content={
"error": "bad_request",
"details": exc.errors(),
},
)
Catch-all error handler¶
@app.exception_handler(Exception)
async def all_errors(req: Request, exc: Exception):
# Log the real error here — don't leak details to the client
return JSONResponse(
status_code=500,
content={"error": "internal server error"},
)
Real-world example — patient records (preview of next chapter)¶
from fastapi import FastAPI, HTTPException, status
import json
app = FastAPI()
def load_patients():
with open("patients.json") as f:
return json.load(f)
@app.get("/patient/{pid}")
def get_patient(pid: str):
patients = load_patients()
if pid not in patients:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"patient {pid} not found",
)
return patients[pid]
Common pitfalls¶
- ❗ Returning an error dict instead of raising —
return {"error": "..."}sends a 200 OK with an "error" field. Always raiseHTTPException. - ❗ Wrong status code — "user not found" should be 404, not 400. "Invalid input" is 400.
- ❗ Leaking internals —
detail=str(exception)may leak DB error messages or paths. Sanitize. - ❗ Catching
Exceptionand silently swallowing — better to log and let it become 500 than hide a bug. - ❗ Using HTTPException for control flow — only use it at the boundary of the API. Internal validation should use regular exceptions or return values.
What's next¶
Practice¶
What does this print?
Expected: 404
Use HTTPException (not a plain return) to signal an error to the client
Expected: True
Quiz — Quick check¶
What you remember
Q1. How do you return a 404 in FastAPI?
-
raise HTTPException(status_code=404, detail="Not found") -
return 404 -
return {"status": 404} -
exit(404)
Why: HTTPException is the idiomatic way. FastAPI catches it, sets the response status, and returns a JSON body with the detail. Anything else returns 200 by default.
Q2. What's the right status code for a malformed request body?
- 400 Bad Request
- 422 Unprocessable Entity (FastAPI's default for validation errors)
- 500 Internal Server Error
- 404 Not Found
Why: 400 is "couldn't parse the request at all". 422 means "I parsed it but the content is invalid". FastAPI uses 422 for Pydantic validation failures. Custom checks may use either.
Q3. Where should validation errors NOT be caught?
- FastAPI handles Pydantic validation errors automatically — don't try/except them
- In a middleware
- In an exception handler
- In the handler
Why: Pydantic validation errors are converted to 422 responses by FastAPI. Catching them in your handler would defeat the purpose. Customize via
@app.exception_handler(RequestValidationError)if needed.
Common doubts¶
How do I customize the JSON shape of errors?
Add a global exception handler: @app.exception_handler(HTTPException) returning your custom JSON shape. Useful for consistent error envelopes across all endpoints.
What's the difference between HTTPException and a Python exception?
HTTPException is FastAPI's hand-shake: "stop processing, return this HTTP response". Other exceptions become 500 Internal Server Error (unless caught by a handler). Use HTTPException for expected errors (404, 401, 422); let unexpected exceptions become 500s.
Should error messages reveal internal details?
No. In production, return generic messages to clients ("User not found", "Internal error") and log the full details server-side. Detailed errors can leak schema, paths, or vulnerabilities to attackers.