Error Handling
Table of Contents
Turning exceptions into consistent, machine-readable API responses.
The Defaults
FastAPI ships with HTTPException for quick error responses:
from fastapi import HTTPException
@router.get("/users/{user_id}")
async def get_user(user_id: int, db: DbSession) -> UserResponse:
user = await db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse.model_validate(user)This returns {"detail": "User not found"} with a 404 status code.
Note
detailaccepts any JSON-serializable value, not just strings. You can pass a dict or list for structured errors:raise HTTPException( status_code=400, detail={"code": "INVALID_STATE", "message": "Booking is already confirmed"}, )
HTTPException works fine for simple APIs. But as your app grows, you’ll want:
- A consistent error shape across all endpoints
- Error types that carry domain semantics (
NotFoundError, notHTTPException(404)) - Machine-readable error codes, not just human messages
Custom Exceptions
Define a base exception that carries everything needed to build an error response:
# src/exceptions.py
class AppError(Exception):
"""Base exception for all application errors."""
def __init__(
self,
status_code: int = 500,
title: str = "Internal Server Error",
detail: str = "",
) -> None:
self.status_code = status_code
self.title = title
self.detail = detail
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id: object) -> None:
super().__init__(
status_code=404,
title="Not Found",
detail=f"{resource} {resource_id} not found",
)
class ConflictError(AppError):
def __init__(self, detail: str = "Resource already exists") -> None:
super().__init__(status_code=409, title="Conflict", detail=detail)
class ForbiddenError(AppError):
def __init__(self, detail: str = "Insufficient permissions") -> None:
super().__init__(status_code=403, title="Forbidden", detail=detail)Now route handlers read like domain code:
@router.get("/users/{user_id}")
async def get_user(user_id: int, db: DbSession) -> UserResponse:
user = await db.get(User, user_id)
if not user:
raise NotFoundError("User", user_id)
return UserResponse.model_validate(user)Exception Handlers
FastAPI needs a handler to convert AppError into an HTTP response. Keep handlers in their own module to avoid cluttering create_app:
# src/exception_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from src.exceptions import AppError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"title": exc.title, "detail": exc.detail},
)# src/app.py
from src.exception_handlers import register_exception_handlers
def create_app() -> FastAPI:
app = FastAPI(...)
register_exception_handlers(app)
# ... routers, middleware ...
return appAny AppError (or subclass) raised anywhere — in route handlers, dependencies, or services — is now caught and serialized consistently.
Problem-Detail Responses (RFC 9457)
RFC 9457 defines a standard JSON shape for API errors. Adopting it means every client, SDK generator, and monitoring tool can parse your errors without custom logic.
A problem-detail response looks like this:
{
"type": "https://my-api.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "User 42 not found",
"instance": "/users/42"
}You don’t need a library for this — it’s a Pydantic model and an exception handler:
# src/schemas/errors.py
from pydantic import BaseModel
class ProblemDetail(BaseModel):
type: str = "about:blank"
title: str
status: int
detail: str = ""
instance: str = ""# src/exception_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from src.exceptions import AppError
from src.schemas.errors import ProblemDetail
ERROR_TYPE_BASE = "https://my-api.example.com/errors"
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
slug = exc.title.lower().replace(" ", "-")
body = ProblemDetail(
type=f"{ERROR_TYPE_BASE}/{slug}",
title=exc.title,
status=exc.status_code,
detail=exc.detail,
instance=str(request.url.path),
)
return JSONResponse(
status_code=exc.status_code,
content=body.model_dump(exclude_none=True),
media_type="application/problem+json",
)Note
Setting
media_type="application/problem+json"is part of the RFC spec. It tells clients the body follows the problem-detail format. Most JSON parsers handle it transparently.
This replaces the simpler handler from the previous section — the AppError hierarchy stays exactly the same.
Warning
The
typeURI derived fromexc.titleis a quick start, but RFC 9457 expects type URIs to be stable identifiers. If a title changes, the URI changes, breaking clients that match on it. For production APIs, define type URIs as explicit constants on each exception class (e.g.error_type = "not-found") instead of deriving them from presentation text.
Reshaping Validation Errors
FastAPI’s default response for invalid request data (RequestValidationError) looks like this:
{
"detail": [
{
"type": "missing",
"loc": ["body", "email"],
"msg": "Field required",
"input": {},
"url": "https://errors.pydantic.dev/2.11/v/missing"
}
]
}The url field (added by Pydantic v2) is noisy for API consumers, and the shape doesn’t match the problem-detail format. Add a handler to register_exception_handlers with a second handler for RequestValidationError:
# src/exception_handlers.py (continued)
from fastapi.exceptions import RequestValidationError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(...) -> JSONResponse: ... # as above
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
errors = []
for err in exc.errors():
errors.append({
"field": ".".join(str(loc) for loc in err["loc"]),
"message": err["msg"],
"type": err["type"],
})
body = ProblemDetail(
type=f"{ERROR_TYPE_BASE}/validation-error",
title="Validation Error",
status=422,
detail=f"{len(errors)} validation error(s)",
instance=str(request.url.path),
)
payload = body.model_dump()
payload["errors"] = errors
return JSONResponse(
status_code=422,
content=payload,
media_type="application/problem+json",
)The response now looks like this:
{
"type": "https://my-api.example.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "2 validation error(s)",
"instance": "/users/",
"errors": [
{"field": "body.email", "message": "Field required", "type": "missing"},
{"field": "body.password", "message": "Field required", "type": "missing"}
]
}Warning
The
errorsextension field is allowed by RFC 9457 (the spec is extensible), but not all tools will know about it. The top-leveltitle,status, anddetailfields are always enough for a human or a generic error handler to understand what went wrong.
Warning
Reshaping
RequestValidationErrorchanges the response contract. Clients and SDK generators that expect FastAPI’s native validation shape ({"detail": [...]}) will break. Decide early whether your API uses the default shape or problem-detail — switching later is a breaking change.