Exception Handling
Learn how to raise clean HTTP errors, define your own exception classes, and register global handlers that shape every error response your API ever returns. Done right, exception handling makes your API predictable, debuggable, and client-friendly.
HTTPException
HTTPException is FastAPI's built-in way to stop a request and immediately
send an error response. You raise it anywhere in your route or dependency and
FastAPI catches it, converts it to JSON, and sets the right HTTP status code — all
automatically.
HTTPException
HTTP status codes are 3-digit numbers that tell the client what happened.
FastAPI uses the status_code parameter of HTTPException.
Always import status from fastapi — it gives you
readable constants instead of magic numbers.
| Code | Meaning | Use when… |
|---|---|---|
| 400 | Bad Request | Malformed input the client sent |
| 401 | Unauthorized | No / invalid credentials provided |
| 403 | Forbidden | Credentials valid but not allowed |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource / state conflict |
| 422 | Unprocessable | Pydantic validation failures (auto) |
| 500 | Internal Error | Unhandled server-side bug |
from fastapi import FastAPI, HTTPException, status app = FastAPI() # Fake DB items = {1: "Sword", 2: "Shield"} @app.get("/items/{item_id}") async def get_item(item_id: int): if item_id not in items: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, # readable constant detail=f"Item {item_id} not found" ) return {"item": items[item_id]} # GET /items/99 → {"detail": "Item 99 not found"} HTTP 404 # GET /items/1 → {"item": "Sword"} HTTP 200
status.HTTP_404_NOT_FOUND over the raw integer 404.
It makes code self-documenting and catches typos at import time.
The detail field can be a string, a dict,
or even a list — FastAPI JSON-encodes whatever you pass.
You can also attach custom response headers via the headers parameter
(useful for authentication challenges like WWW-Authenticate).
from fastapi import FastAPI, HTTPException, status app = FastAPI() # ── 1. Plain string detail ────────────────────────── @app.get("/a") async def route_a(): raise HTTPException( status_code=400, detail="username must be at least 3 characters" ) # Response: {"detail": "username must be at least 3 characters"} # ── 2. Dict detail (structured error) ─────────────── @app.get("/b") async def route_b(): raise HTTPException( status_code=422, detail={ "field": "email", "error": "not a valid email address", "input": "not-an-email" } ) # ── 3. Custom headers ──────────────────────────────── @app.get("/protected") async def protected(): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"} # Browser will see this header in the response )
detail value in {"detail": ...}
— this is the default shape. You can override this shape entirely with a global
exception handler (covered in 9.3).Custom Exceptions
Raw HTTPExceptions scattered everywhere mix HTTP concerns into your business
logic. The better pattern: define your own exception classes for domain scenarios, then let
a single global handler convert them to HTTP responses.
Domain exceptions represent business rule violations — things that go wrong in your application logic, not at the HTTP layer. Create a hierarchy rooted at a base class, then define specific subclasses for each scenario.
# ── Step 1: define the exception hierarchy ────────── class AppException(Exception): """Base for all application-level exceptions.""" def __init__(self, message: str, code: str = "app_error"): self.message = message self.code = code # machine-readable error code super().__init__(message) class NotFoundException(AppException): def __init__(self, resource: str, id: int | str): super().__init__( message=f"{resource} with id={id} not found", code="not_found" ) class ConflictException(AppException): def __init__(self, message: str): super().__init__(message, code="conflict") class ForbiddenException(AppException): def __init__(self, message: str = "Access denied"): super().__init__(message, code="forbidden")
# ── Step 2: service raises domain exceptions ──────── from exceptions.domain import NotFoundException, ConflictException USERS = {1: {"name": "Alice", "email": "alice@example.com"}} class UserService: def get_user(self, user_id: int) -> dict: user = USERS.get(user_id) if not user: raise NotFoundException("User", user_id) # domain exception return user def create_user(self, email: str) -> dict: existing = [u for u in USERS.values() if u["email"] == email] if existing: raise ConflictException(f"Email {email} already registered") # ... create and return user
FastAPI automatically raises RequestValidationError when Pydantic
fails to parse the incoming request body. You can catch and reformat
this error to give clients friendlier messages. You can also define your own
validation exception for business-rule validations.
# What FastAPI sends automatically when a body fails Pydantic validation: { "detail": [ { "type": "missing", "loc": ["body", "email"], "msg": "Field required", "input": {"name": "Alice"} } ] } # The "loc" array shows exactly which field failed — very useful for clients
# For business-rule validation (not Pydantic), define your own: class ValidationException(AppException): """Raised when business rules reject otherwise-valid input.""" def __init__(self, field: str, message: str): self.field = field super().__init__(message=message, code="validation_error") # Example usage in a service: def set_age(age: int): if age < 0 or age > 150: raise ValidationException( field="age", message="Age must be between 0 and 150" )
from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse app = FastAPI() @app.exception_handler(RequestValidationError) async def validation_exception_handler( request: Request, exc: RequestValidationError ): # Reformat the Pydantic errors into a cleaner structure errors = [ { "field": " → ".join(str(loc) for loc in err["loc"]), "message": err["msg"], "type": err["type"] } for err in exc.errors() ] return JSONResponse( status_code=422, content={"status": "error", "errors": errors} ) # POST /users with missing email now returns: # {"status": "error", "errors": [{"field":"body → email","message":"Field required","type":"missing"}]}
Global Exception Handlers
Instead of catching exceptions in every route, register global handlers
with @app.exception_handler(ExceptionClass). FastAPI calls the matching
handler whenever that exception type propagates out of any route, dependency, or middleware.
Use @app.exception_handler(SomeException) to register a handler.
The handler receives the Request and the exception instance, and
must return a Response object. You can register handlers for:
from fastapi import FastAPI, Request, HTTPException, status from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError # --- our domain exceptions --- class AppException(Exception): def __init__(self, message: str, code: str, http_status: int): self.message = message self.code = code self.http_status = http_status super().__init__(message) class NotFoundException(AppException): def __init__(self, resource: str, id): super().__init__( message=f"{resource} #{id} not found", code="not_found", http_status=404 ) class ConflictException(AppException): def __init__(self, msg: str): super().__init__(msg, "conflict", 409) app = FastAPI() # ── Handler 1: our domain exceptions ──────────────── @app.exception_handler(AppException) async def app_exception_handler(request: Request, exc: AppException): return JSONResponse( status_code=exc.http_status, content={ "status": "error", "code": exc.code, "detail": exc.message, "path": str(request.url) } ) # ── Handler 2: Pydantic validation errors ──────────── @app.exception_handler(RequestValidationError) async def validation_handler(request: Request, exc: RequestValidationError): return JSONResponse( status_code=422, content={ "status": "error", "code": "validation_error", "errors": exc.errors() } ) # ── Handler 3: catch-all for unhandled exceptions ──── @app.exception_handler(Exception) async def generic_handler(request: Request, exc: Exception): return JSONResponse( status_code=500, content={ "status": "error", "code": "internal_error", "detail": "An unexpected error occurred" # Never leak exc details in production! } ) # ── Routes that use domain exceptions ──────────────── USERS = {1: {"name": "Alice"}} @app.get("/users/{user_id}") async def get_user(user_id: int): if user_id not in USERS: raise NotFoundException("User", user_id) # ← domain exception return USERS[user_id]
A good API always returns errors in the same predictable shape. This lets client developers write a single error-handling function instead of special-casing every endpoint. Here's a recommended standardised error schema:
{
"status": "error", // always "error" for failures
"code": "not_found", // machine-readable snake_case code
"detail": "User #99 not found", // human-readable message
"path": "/users/99", // the URL that was called
"request_id": "a1b2c3d4" // trace/correlation ID (optional)
}
# errors/handlers.py import uuid, logging from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError logger = logging.getLogger(__name__) def _error_response(status_code: int, code: str, detail: str, request: Request, extra: dict = {}) -> JSONResponse: request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8]) return JSONResponse( status_code=status_code, content={ "status": "error", "code": code, "detail": detail, "path": str(request.url.path), "request_id": request_id, **extra } ) def register_exception_handlers(app: FastAPI) -> None: """Call this once from main.py to wire up all handlers.""" @app.exception_handler(HTTPException) async def http_handler(request: Request, exc: HTTPException): code_map = { 400: "bad_request", 401: "unauthorized", 403: "forbidden", 404: "not_found", 409: "conflict", 500: "internal_error" } code = code_map.get(exc.status_code, f"http_{exc.status_code}") return _error_response(exc.status_code, code, exc.detail, request) @app.exception_handler(RequestValidationError) async def validation_handler(request: Request, exc: RequestValidationError): errors = [ {"field": ".".join(str(l) for l in e["loc"]), "msg": e["msg"]} for e in exc.errors() ] return _error_response(422, "validation_error", "Request validation failed", request, {"errors": errors}) @app.exception_handler(Exception) async def generic_handler(request: Request, exc: Exception): logger.exception("Unhandled exception", exc_info=exc) return _error_response(500, "internal_error", "An unexpected error occurred", request) # main.py from fastapi import FastAPI from errors.handlers import register_exception_handlers app = FastAPI() register_exception_handlers(app) # one line wires everything up
• Always return the same JSON shape for all errors
• Include a machine-readable code (snake_case), not just a status code
• Add a request_id / trace ID so logs can be correlated
• Log full stack trace server-side, never expose it in the response body
• Use a catch-all
Exception handler to prevent raw 500 HTML leaking
Full exception-handling architecture at a glance:
│ raises NotFoundException (domain exception, no HTTP imports)
│
▼
FastAPI exception handling
│ looks for handler registered for AppException (base class) ← matches!
│
▼
app_exception_handler()
│ builds uniform JSON body + correct HTTP status code
│
▼
JSONResponse(status_code=404, content={...}) → Client
| Tool | When to use | Key import |
|---|---|---|
HTTPException |
Quick HTTP errors inside routes — simple cases | from fastapi import HTTPException |
status.HTTP_xxx |
Always — instead of magic integers | from fastapi import status |
| Domain Exception classes | Service/repository layer — no HTTP imports | your own exceptions/ module |
RequestValidationError |
Override Pydantic's default 422 format | from fastapi.exceptions import RequestValidationError |
@app.exception_handler |
Register global handlers — catch any exception type | decorator on the app instance |
Catch-all Exception handler |
Prevent raw 500 HTML / traceback leaking to clients | @app.exception_handler(Exception) |