Error handling in FastAPI: practical tips and trade-offs
Building a production API means handling not just the happy path, but every edge case your users and systems can throw at it. We've learned through hard experience that inconsistent error responses create confusion for clients, make debugging painful, and often expose internal details you'd rather keep private. In this guide, we'll walk through how to implement robust error handling in FastAPI—balancing clarity for API consumers with the information you need for troubleshooting.
Why consistent error responses matter
Picture this: one endpoint returns {"detail": "User not found"}, another returns {"error": "user_not_found", "message": "..."}, and a third crashes with a 500 after logging a stack trace. Your frontend team scrambles to handle three different response shapes. Your ops team struggles to spot patterns in errors. Consistency saves time and reduces friction across the entire platform.
We typically want three layers of error handling:
- Expected domain errors (user not found, invalid input for a business rule)
- HTTP validation errors (malformed JSON, missing required fields)
- Unexpected system errors (database connection lost, out of memory)
Each deserves its own treatment, but they should all speak the same language to your clients.
Raise HTTPException for expected errors
FastAPI's HTTPException is your front-line tool for errors you know will happen. Use it for anything predictable: a resource doesn't exist, the user lacks permission, a validation rule failed. It's clean, idiomatic, and requires minimal setup.
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
app = FastAPI()
class OrderNotFoundError(Exception):
pass
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
order = await fetch_order(order_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
return order
@app.post("/orders")
async def create_order(data: OrderCreate):
if data.quantity <= 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Quantity must be positive"
)
return await save_order(data)The benefit is immediate: FastAPI catches it, returns the status code and detail as JSON, and moves on. The client gets a predictable response. However, HTTPException alone doesn't scale well once you have domain logic spread across services, repositories, and business logic layers. You'll eventually raise domain exceptions deep in your code and need a way to catch and convert them consistently.
Register handlers for domain exceptions
As your codebase grows, your business logic will raise custom exceptions—things like InsufficientBalanceError, SubscriptionExpiredError, or DuplicateEmailError. These are not HTTPExceptions; they're part of your domain. You want to catch them at the API boundary and translate them into HTTP responses.
FastAPI's exception handlers let you do this cleanly:
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse
# Domain exceptions
class InsufficientBalanceError(Exception):
def __init__(self, required: float, available: float):
self.required = required
self.available = available
class SubscriptionExpiredError(Exception):
pass
class DuplicateEmailError(Exception):
pass
app = FastAPI()
@app.exception_handler(InsufficientBalanceError)
async def handle_insufficient_balance(request, exc):
return JSONResponse(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
content={
"error": "insufficient_balance",
"required": exc.required,
"available": exc.available
}
)
@app.exception_handler(SubscriptionExpiredError)
async def handle_subscription_expired(request, exc):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"error": "subscription_expired"}
)
@app.exception_handler(DuplicateEmailError)
async def handle_duplicate_email(request, exc):
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"error": "duplicate_email"}
)
# Usage in route handlers
@app.post("/transfer")
async def transfer_funds(amount: float, to_user_id: int):
sender = await get_current_user()
if sender.balance < amount:
raise InsufficientBalanceError(required=amount, available=sender.balance)
await save_transfer(sender.id, to_user_id, amount)
return {"status": "success"}This pattern separates concerns beautifully. Your domain logic doesn't know about HTTP; it raises exceptions that make semantic sense. Your handlers sit at the boundary and decide what HTTP response that means. If you need to add a new error type, you define the exception and its handler, and all your route handlers benefit automatically.
A practical tip: define your domain exceptions in a shared module so that any part of your codebase can raise them. Keep handler registration in a central place—often a dedicated exceptions.py module or a startup callback—so you don't scatter handlers across route files.
Customize RequestValidationError output
When Pydantic validates request data and fails—maybe a field is the wrong type or a required field is missing—FastAPI returns a 422 with validation errors. By default, the response is verbose and can leak information about your schema structure. You can customize it to be more consistent with your other error responses.
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(x) for x in error["loc"][1:]),
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "validation_failed",
"details": errors
}
)Now validation errors follow your standard structure. The client gets a consistent error shape regardless of whether the problem is a missing field or a business rule violation. Be cautious with the "type" field—it can expose Pydantic internals. Some teams strip it entirely or map it to user-friendly values.
There's a trade-off here: the default response is more detailed for developers debugging client code, but your custom response is cleaner and more secure. We recommend customizing it for production APIs but keeping the default during early development when tight feedback loops matter more.
Log unexpected errors with request context
Not every error your API encounters is expected. Databases go down, third-party services timeout, code has bugs. These 5xx errors need logging with enough context to debug them later. You want to capture the request details, any relevant state, and the full traceback—but you do not want to return those internals to the client.
import logging
import traceback
from fastapi import Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
request_body = ""
try:
request_body = await request.body()
request_body = request_body.decode("utf-8")
except:
pass
logger.error(
"Unhandled exception",
exc_info=exc,
extra={
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"request_body": request_body[:500], # Truncate for safety
"client_ip": request.client.host if request.client else None,
}
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "internal_error",
"message": "An unexpected error occurred. Please contact support if the problem persists."
}
)This catch-all handler logs the exception with its traceback and request context, then returns a generic, safe response to the client. Your ops team has what they need to investigate; your API doesn't leak internals.
One important consideration: this global handler will catch HTTPException and your custom domain exceptions too, unless you register their handlers first. FastAPI processes exception handlers in the order you define them, so put specific handlers (like domain exceptions) before the global one.
Another practical detail: reading request.body() consumes the stream, which can cause problems if middleware or route handlers also try to read it. If you're using this pattern, consider using a middleware that stores the body in the request state on first read, or skip logging the body in favor of just logging metadata.
Common mistakes
- Not setting appropriate status codes. A duplicate email error should be 409 Conflict, not 400 Bad Request. Your client's retry logic might depend on status codes. Pick them thoughtfully.
- Logging too much or too little. Logging every successful request and every 4xx error will drown your logs. Log 5xx errors, slow requests, and security-relevant events. Let your metrics system track counts of successful requests and expected client errors.
- Mixing HTTPException with domain exceptions inconsistently. Pick a pattern and stick with it. If some routes raise HTTPException and others raise domain exceptions, code becomes harder to reason about.
- Returning internal error details to clients in production. Stack traces, database error messages, and library versions are debugging gold for attackers. Strip them in production; log them server-side.
- Forgetting to handle nested exceptions. If your domain logic calls a library that raises its own exceptions, wrap them explicitly. Don't let
requests.ConnectionErrororsqlalchemy.exc.DatabaseErrorleak to your client.
Key takeaways
- Use
HTTPExceptionfor simple, inline errors; register exception handlers for domain logic errors that need consistent translation across your codebase. - Customize
RequestValidationErrorto match your standard error response shape, balancing developer clarity with security and consistency. - Define domain exceptions in a shared module and register their handlers in one central place to avoid scattering error logic across route files.
- Catch all unexpected exceptions with a global handler that logs request context and the full traceback, but returns a generic, safe response to the client.
- Choose HTTP status codes thoughtfully—409 for conflicts, 402 for payment issues, 403 for permissions—since clients may use them to decide whether to retry or fail fast.
- Log enough to debug, but not so much that logs become noise. Focus on 5xx errors, security events, and performance anomalies; let metrics track counts of expected errors.






Comments
Loading comments…