Error handling is the difference between an APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. that developers trust and one that makes them lose hours to debugging. When your API returns a clear, consistent error response, the frontend developer knows exactly what went wrong. When it returns a raw Python traceback or a vague "Internal Server Error," everyone suffers.
FastAPI provides excellent error handling primitives. The problem is that AI-generated code rarely uses them correctly, defaulting to patterns that work in development but are dangerous in production.
HTTPException basics
FastAPI's HTTPException is the standard way to signal an error from a route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes.. You raise it like a regular Python exception, and FastAPI catches it and converts it into an HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. response.
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await find_user(user_id)
if not user:
raise HTTPException(
status_code=404,
detail="User not found"
)
return userThe response body for a raised HTTPException looks like this:
{
"detail": "User not found"
}You can also pass a dictionary as detail for richer error information:
raise HTTPException(
status_code=404,
detail={
"message": "User not found",
"user_id": user_id,
"suggestion": "Check that the user ID is correct"
}
)And you can add custom headers to the error response:
raise HTTPException(
status_code=401,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"}
)HTTPException from FastAPI is different from Starlette's HTTPException. FastAPI's version accepts any JSON-serializable detail, while Starlette's only accepts strings. Always import from fastapi, not starlette.Custom exception handlers
Sometimes you want to handle exceptions that are not HTTPException, a database connection error, a third-party APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. timeout, or your own custom exception classes. That is where @app.exception_handler comes in.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class ItemOutOfStockError(Exception):
def __init__(self, item_id: str, requested: int, available: int):
self.item_id = item_id
self.requested = requested
self.available = available
app = FastAPI()
@app.exception_handler(ItemOutOfStockError)
async def out_of_stock_handler(request: Request, exc: ItemOutOfStockError):
return JSONResponse(
status_code=409,
content={
"error": "out_of_stock",
"message": f"Item {exc.item_id} has {exc.available} units available, "
f"but {exc.requested} were requested",
"item_id": exc.item_id,
"available": exc.available,
},
)Now any route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes. can simply raise ItemOutOfStockError(...) and the custom handler converts it into a clean 409 response. The route handler code stays focused on business logic, and error formatting is centralized.
You can register handlers for any exception type, including Python built-in exceptions:
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
return JSONResponse(
status_code=400,
content={
"error": "validation_error",
"message": str(exc),
},
)Pydantic validation errors
FastAPI uses Pydantic to validate request bodies, query parameters, and path parameters. When validation fails, FastAPI automatically returns a 422 Unprocessable Entity response with detailed error information.
from pydantic import BaseModel, Field
class CreateUser(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: str
age: int = Field(ge=0, le=150)
@app.post("/users")
async def create_user(user: CreateUser):
return {"message": f"Created {user.name}"}If you send {"name": "", "email": "test@example.com", "age": -5}, FastAPI returns:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "name"],
"msg": "String should have at least 1 character",
"input": "",
"ctx": {"min_length": 1}
},
{
"type": "greater_than_equal",
"loc": ["body", "age"],
"msg": "Input should be greater than or equal to 0",
"input": -5,
"ctx": {"ge": 0}
}
]
}This is extremely detailed, almost too detailed. The loc array tells you exactly where the error is (body, query, path), the type gives a machine-readable error code, and msg gives a human-readable explanation.
The problem is that this format is different from your HTTPException error format. Your 404 returns {"detail": "User not found"} (a string), while your 422 returns {"detail": [...]} (an array). Frontend developers have to handle two different shapes.
Designing a consistent error shape
Production APIs need a single, predictable error format. Every error, whether it is a 400, 404, 422, or 500, should return the same JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it. structure. This lets frontend developers write one error handling function instead of many.
Here is a common pattern:
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
app = FastAPI()
# Override the default HTTPException handler
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": True,
"status": exc.status_code,
"message": exc.detail if isinstance(exc.detail, str) else str(exc.detail),
"request_id": getattr(request.state, "request_id", None),
},
)
# Override the default validation error handler
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
errors.append({
"field": " → ".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
"type": error["type"],
})
return JSONResponse(
status_code=422,
content={
"error": True,
"status": 422,
"message": "Validation failed",
"details": errors,
"request_id": getattr(request.state, "request_id", None),
},
)
# Catch-all for unhandled exceptions
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
# Log the full error server-side
logger.error(
"unhandled_exception",
extra={
"error": str(exc),
"traceback": traceback.format_exc(),
"path": request.url.path,
"request_id": getattr(request.state, "request_id", None),
},
)
# Return a clean response to the client
return JSONResponse(
status_code=500,
content={
"error": True,
"status": 500,
"message": "An internal error occurred",
"request_id": getattr(request.state, "request_id", None),
},
)Now every error response has the same shape:
| Field | Type | Description |
|---|---|---|
error | boolean | Always true for error responses |
status | integer | HTTP status code (redundant but convenient for the client) |
message | string | Human-readable error description |
details | array (optional) | Field-level validation errors, only present on 422 |
request_id | string (optional) | Correlation ID for tracing in logs |
How AI handles errors, and why it is dangerous
When you ask AI to "add error handling" to a FastAPI app, here is what you typically get:
# AI-generated error handling - DO NOT use in production
@app.exception_handler(Exception)
async def generic_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": str(exc),
"traceback": traceback.format_exc(),
"path": str(request.url),
},
)This has three serious problems:
Stack traces in responses. The traceback.format_exc() call returns the full Python traceback, including file paths, line numbers, and sometimes sensitive data like database connection strings or APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. keys. This is an information disclosure vulnerability. Attackers can use stack traces to understand your application structure, find vulnerable dependencies, and craft targeted attacks.
Everything becomes a 500. The catch-all handler returns 500 for every unhandled exception, even when the real cause is a 400 (bad input) or a 503 (downstream service unavailable). This makes monitoring useless, you cannot tell real server errors from client mistakes.
No correlation ID. Without a request ID in the error response, when a user reports "I got an error," you have no way to find the corresponding log entry. You are left searching through thousands of log lines hoping to match timestamps.
Error handling hierarchy
FastAPI checks exception handlers in a specific order:
- Exact type match: if you registered a handler for
ItemOutOfStockError, it catches only that exact type - Parent class match: if no exact handler exists, FastAPI walks up the inheritance chain
- Default handlers:
HTTPExceptionandRequestValidationErrorhave built-in handlers that you can override - Catch-all: a handler for
Exceptioncatches everything that no other handler matched
This means you can layer your handlers from specific to generic:
@app.exception_handler(ItemOutOfStockError) # Most specific
@app.exception_handler(BusinessLogicError) # Category
@app.exception_handler(HTTPException) # Framework errors
@app.exception_handler(RequestValidationError) # Validation errors
@app.exception_handler(Exception) # Catch-all (last resort)Production checklist for error handling
Before deploying a FastAPI application, verify these points:
| Check | Why it matters |
|---|---|
| No stack traces in responses | Information disclosure vulnerability |
| All errors return the same JSON shape | Frontend developers need one error handler, not five |
| 500 handler logs the full traceback server-side | You need the details for debugging, just not in the response |
| Every error response includes a request ID | Enables tracing from user report to server log |
| Validation errors (422) include field-level details | Frontend needs to know which fields failed |
| Custom business exceptions have dedicated handlers | Prevents catch-all from swallowing meaningful errors |