KAHIBARO
Discord Login Register

8.7. Exception Handling

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:

In FastAPI, exception handling is built on top of Starlette. You can use:

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:

Basic usage:

python
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:

json
{
  "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:

python
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:

python
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:

python
@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:

These make your code more readable and allow centralized handling.

Basic custom exception:

python
class OutOfStockError(Exception):
    def __init__(self, item_id: int):
        self.item_id = item_id

You 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

python
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:

  1. Catch and convert to HTTPException locally, or
  2. Let a global exception handler handle it (recommended)

Local handling:

python
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:

python
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:

Full example

python
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:

http
GET /buy/1

Response:

json
{
  "detail": "Item 1 is out of stock"
}

Handling library or framework exceptions

You can also handle exceptions raised by libraries. For example, SQLAlchemy:

python
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:

json
{
  "detail": [
    {
      "loc": ["body", "item", "price"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

Sometimes you want your own error format.

Custom handler for `RequestValidationError`

python
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:

json
{
  "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

python
from pydantic import BaseModel
class Item(BaseModel):
    name: str
    price: float
    quantity: int
@app.post("/items")
def create_item(item: Item):
    return item

If you send:

json
{
  "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:

python
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:

json
{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User 123 not found",
    "details": null
  }
}

Or:

json
{
  "detail": "Human readable message",
  "code": "SOME_CODE",
  "errors": []
}

Whatever structure you choose, stick with it.

Defining an error schema with Pydantic

python
from pydantic import BaseModel
from typing import Optional, Any, List
class APIError(BaseModel):
    code: str
    message: str
    details: Optional[Any] = None

Use it in handlers:

python
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:

python
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:

python
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 user

Here:

Practical Patterns and Tips

Pattern 1: Domain exceptions plus handlers

  1. Define domain-specific exceptions in your "service" or "domain" layer.
  2. Register global handlers in your FastAPI app that map them to HTTP responses.

Example table:

Domain exceptionHTTP statusExample code
UserNotFoundError404USER_NOT_FOUND
DuplicateUserError409USER_ALREADY_EXISTS
PermissionDeniedError403PERMISSION_DENIED
OutOfStockError400OUT_OF_STOCK

This keeps business logic free of HTTP concerns.

Pattern 2: Local vs global handling

Example local handling:

python
@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 result

Debug vs production behavior

In development, you often want to see full tracebacks in the browser. In production, you want:

Use environment variables and settings (covered in other chapters) to switch between modes.


Summary

With these tools, your FastAPI application can fail gracefully, give clear feedback to clients, and remain safe and maintainable as it grows.

Views: 14

Comments

Please login to add a comment.

Don't have an account? Register now!