8.7. Exception Handling
Table of Contents
Why Exception Handling Matters in FastAPI
Every real application fails at some point. Files are missing, databases are down, users send invalid data, or you simply have a bug. If you do nothing, FastAPI will return a generic 500 Internal Server Error with a traceback in the logs.
Exception handling is about:
- Returning clear, consistent error responses to clients
- Hiding internal details of your code and infrastructure
- Logging errors so you can debug them
- Converting low-level errors into meaningful API errors
In FastAPI, exception handling is built on top of Starlette. You can use:
- Built-in exceptions like
HTTPException - Custom exception classes
- Global exception handlers
You will use all three in a real project.
Using `HTTPException`
The most common way to signal an error in a FastAPI route is to raise HTTPException. This tells FastAPI:
- Which HTTP status code to use
- What error message to send
- Optional custom headers to add
Basic usage:
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int):
fake_db = {1: "Apple", 2: "Banana"}
if item_id not in fake_db:
raise HTTPException(
status_code=404,
detail="Item not found",
)
return {"id": item_id, "name": fake_db[item_id]}The JSON response looks like:
{
"detail": "Item not found"
}Common patterns with `HTTPException`
1. Returning 400 Bad Request
Use this when the client did something wrong, but validation did not catch it:
from fastapi import HTTPException, status
@app.post("/transfer")
def transfer_money(amount: float):
if amount <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Amount must be greater than zero"
)
return {"status": "ok"}2. Returning 401 Unauthorized or 403 Forbidden
Use for authentication or authorization issues:
from fastapi import Depends
def get_current_user(token: str):
if token != "secret":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return {"username": "alice"}
@app.get("/profile")
def read_profile(user=Depends(get_current_user)):
return user
Note the WWW-Authenticate header. Many auth flows depend on it.
3. Returning 409 Conflict, 422, etc.
You can use any status code:
@app.post("/users")
def create_user(username: str):
existing_users = {"alice", "bob"}
if username in existing_users:
raise HTTPException(
status_code=409,
detail=f"User '{username}' already exists"
)
return {"username": username}
::danger
Rule: Use HTTPException to represent expected API errors, not programming bugs. Bugs should usually result in a 500 error and be logged, not turned into a fake "valid" response.
Custom Exception Classes
HTTPException is good, but as your app grows you will want domain-specific exceptions, such as:
UserNotFoundErrorOutOfStockErrorPaymentFailedErrorPermissionDeniedError
These make your code more readable and allow centralized handling.
Basic custom exception:
class OutOfStockError(Exception):
def __init__(self, item_id: int):
self.item_id = item_idYou do not return anything here. You just define what information the exception carries. The conversion into an HTTP response will be done by an exception handler, described later.
Example: service layer using custom exceptions
class UserNotFoundError(Exception):
def __init__(self, user_id: int):
self.user_id = user_id
def get_user_from_db(user_id: int):
fake_db = {1: "Alice"}
if user_id not in fake_db:
raise UserNotFoundError(user_id)
return {"id": user_id, "name": fake_db[user_id]}In the route you can either:
- Catch and convert to
HTTPExceptionlocally, or - Let a global exception handler handle it (recommended)
Local handling:
from fastapi import HTTPException, status
@app.get("/users/{user_id}")
def get_user(user_id: int):
try:
return get_user_from_db(user_id)
except UserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {exc.user_id} not found",
)This works but repeats error conversion logic. A centralized handler is cleaner.
Global Exception Handlers
FastAPI lets you register a handler for any exception type. This handler is called whenever that exception is raised anywhere in your app, including dependencies.
General structure:
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(OutOfStockError)
async def out_of_stock_handler(request: Request, exc: OutOfStockError):
return JSONResponse(
status_code=400,
content={"detail": f"Item {exc.item_id} is out of stock"},
)Now:
- Any
raise OutOfStockError(42)in your code will return a 400 JSON error. - You do not need to wrap every call in
try/except.
Full example
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class OutOfStockError(Exception):
def __init__(self, item_id: int):
self.item_id = item_id
inventory = {
1: {"name": "Laptop", "stock": 0},
2: {"name": "Mouse", "stock": 5},
}
def purchase_item(item_id: int):
item = inventory.get(item_id)
if item is None:
raise KeyError(f"Item {item_id} does not exist")
if item["stock"] <= 0:
raise OutOfStockError(item_id)
item["stock"] -= 1
return item
@app.exception_handler(OutOfStockError)
async def out_of_stock_handler(request: Request, exc: OutOfStockError):
return JSONResponse(
status_code=400,
content={"detail": f"Item {exc.item_id} is out of stock"},
)
@app.get("/buy/{item_id}")
def buy(item_id: int):
return purchase_item(item_id)Request:
GET /buy/1Response:
{
"detail": "Item 1 is out of stock"
}Handling library or framework exceptions
You can also handle exceptions raised by libraries. For example, SQLAlchemy:
from sqlalchemy.exc import IntegrityError
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError):
return JSONResponse(
status_code=400,
content={"detail": "Database integrity error"},
)
Now any IntegrityError from any endpoint will turn into a 400 response.
::danger
Rule: Use @app.exception_handler(SomeError) for cross-cutting concerns, like mapping your domain exceptions and library errors to proper HTTP responses in one place.
Handling Validation Errors
FastAPI automatically validates request bodies, query parameters, path parameters, etc. When validation fails, it raises RequestValidationError. There is also ValidationError from Pydantic when model validation fails manually.
By default, FastAPI returns a 422 response like:
{
"detail": [
{
"loc": ["body", "item", "price"],
"msg": "field required",
"type": "value_error.missing"
}
]
}Sometimes you want your own error format.
Custom handler for `RequestValidationError`
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi import Request, status
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
errors.append({
"field": loc,
"message": err["msg"],
"type": err["type"],
})
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"errors": errors},
)Now validation errors will look like:
{
"errors": [
{
"field": "body.item.price",
"message": "field required",
"type": "value_error.missing"
}
]
}This can be easier for frontend developers to work with.
Example endpoint with validation
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
quantity: int
@app.post("/items")
def create_item(item: Item):
return itemIf you send:
{
"name": "Pen",
"price": "not-a-number",
"quantity": 5
}Your custom handler will format the error response.
Handling Pydantic `ValidationError` directly
If you create models manually, Pydantic can raise ValidationError. You can also write a handler for that:
from pydantic import ValidationError
@app.exception_handler(ValidationError)
async def pydantic_validation_handler(request: Request, exc: ValidationError):
return JSONResponse(
status_code=422,
content={"detail": exc.errors()},
)Returning Consistent Error Responses
In a real API, you want all errors to have a consistent JSON structure. For example:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "User 123 not found",
"details": null
}
}Or:
{
"detail": "Human readable message",
"code": "SOME_CODE",
"errors": []
}Whatever structure you choose, stick with it.
Defining an error schema with Pydantic
from pydantic import BaseModel
from typing import Optional, Any, List
class APIError(BaseModel):
code: str
message: str
details: Optional[Any] = NoneUse it in handlers:
class UserNotFoundError(Exception):
def __init__(self, user_id: int):
self.user_id = user_id
@app.exception_handler(UserNotFoundError)
async def user_not_found_handler(request: Request, exc: UserNotFoundError):
error = APIError(
code="USER_NOT_FOUND",
message=f"User {exc.user_id} not found",
details={"user_id": exc.user_id},
)
return JSONResponse(
status_code=404,
content={"error": error.model_dump()},
)
Now all UserNotFoundError responses have the same shape.
Consistent format for all server errors
You can also handle the catch-all Exception type:
import logging
logger = logging.getLogger(__name__)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# Log the error with stack trace
logger.exception("Unhandled error: %s %s", request.method, request.url)
error = APIError(
code="INTERNAL_SERVER_ERROR",
message="An unexpected error occurred. Please try again later.",
)
return JSONResponse(
status_code=500,
content={"error": error.model_dump()},
)
::danger
Rule: Return a generic message to the client for 500 errors. Never leak stack traces, SQL queries, or secrets in responses.
Combining Exception Handling with Dependencies
Dependencies in FastAPI are a common place for exceptions. For example, authentication:
from fastapi import Depends, Header
class NotAuthenticatedError(Exception):
pass
def get_current_user(authorization: str = Header(None)):
if authorization != "Bearer secret-token":
raise NotAuthenticatedError()
return {"username": "alice"}
@app.exception_handler(NotAuthenticatedError)
async def not_authenticated_handler(request: Request, exc: NotAuthenticatedError):
return JSONResponse(
status_code=401,
content={"detail": "Not authenticated"},
headers={"WWW-Authenticate": "Bearer"},
)
@app.get("/me")
def read_me(user=Depends(get_current_user)):
return userHere:
get_current_usercan be used by many paths.- All failures produce the same 401 error without duplicating logic.
Practical Patterns and Tips
Pattern 1: Domain exceptions plus handlers
- Define domain-specific exceptions in your "service" or "domain" layer.
- Register global handlers in your FastAPI app that map them to HTTP responses.
Example table:
| Domain exception | HTTP status | Example code |
|---|---|---|
UserNotFoundError | 404 | USER_NOT_FOUND |
DuplicateUserError | 409 | USER_ALREADY_EXISTS |
PermissionDeniedError | 403 | PERMISSION_DENIED |
OutOfStockError | 400 | OUT_OF_STOCK |
This keeps business logic free of HTTP concerns.
Pattern 2: Local vs global handling
- Use local
try/exceptwhen that route needs special behavior. - Use global handlers for common errors that should always behave the same.
Example local handling:
@app.get("/dangerous")
def dangerous_route():
try:
result = do_something_risky()
except SpecificError as exc:
# Maybe adjust message or call external service
raise HTTPException(400, detail=str(exc))
return resultDebug vs production behavior
In development, you often want to see full tracebacks in the browser. In production, you want:
- Minimal error message to clients
- Full error details in logs
Use environment variables and settings (covered in other chapters) to switch between modes.
Summary
- Use
HTTPExceptionto return expected HTTP errors from routes and dependencies. - Define custom exception classes for your domain and raise them in business logic.
- Register global exception handlers with
@app.exception_handler(...)to transform exceptions into consistent JSON responses. - Customize handling of validation errors (
RequestValidationErrorandValidationError) to match your API error format. - Centralize error response structure with Pydantic models like
APIError. - Handle authentication and authorization failures via exceptions in dependencies plus corresponding handlers.
- Log internal errors and avoid exposing sensitive details to clients, especially for 500 responses.
With these tools, your FastAPI application can fail gracefully, give clear feedback to clients, and remain safe and maintainable as it grows.
Views: 14
KAHIBARO