Chapter 7: Exception Handling in FastAPI

Let’s Start With a Real Problem

At the end of Chapter 6, we built a 404 response the “manual” way — a hand-written if user is None: return JSONResponse(...) block. It worked. But now imagine this app grown up: 40 endpoints, each with 2-3 possible error cases. That’s 100+ near-identical blocks scattered across the codebase. One day your team decides every error response should also include an error_code field for the frontend. Now you have to find and update all 100+ of them by hand.

This chapter fixes that, once and for all, using exceptions and a global error handler. Every program below is fully self-contained and runnable on its own — build any one of them from a completely empty folder.


What You’ll Learn

  • Why raising exceptions is cleaner than manually returning error responses
  • How to use FastAPI’s built-in HTTPException
  • How to design custom exception classes for domain-specific errors
  • How to register a global exception handler that catches an exception anywhere in the app
  • How to customize FastAPI’s default validation error (422) response
  • How to safely catch unexpected errors without leaking internal details
  • Theory + hands-on assignment questions to lock in what you’ve learned

Theory: Exceptions vs. Manually Returning Responses

In Chapter 6, error handling looked like this:

if user is None:
    return JSONResponse(status_code=404, content={"detail": "not found"})

This is a return-based approach — the function decides what response to send, right there, inline. It mixes two concerns: business logic (“does this user exist?”) and HTTP response formatting (“what JSON shape and status code do we send back?”).

FastAPI encourages a different approach: raise an exception when something goes wrong, and let a separate, centralized piece of code decide how to turn that exception into an HTTP response.

if user is None:
    raise HTTPException(status_code=404, detail="not found")

This small change unlocks something powerful: you can raise this exception from anywhere — a router, a service function, even a dependency — and FastAPI still catches it and builds the correct response, without that code needing to know anything about JSONResponse at all.


Program 1: Using FastAPI’s Built-in HTTPException

Program Objective

Build a small, standalone FastAPI project with a GET /users/{user_id} endpoint that uses HTTPException instead of a manual JSONResponse to return a 404 when a user doesn’t exist.

Step-by-Step Implementation

Step 1: Create the project

uv init httpexception-demo
cd httpexception-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app/schemas app/services app/routers
touch app/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
httpexception-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       └── users.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

app/services/user_service.py

from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id
    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": f"hashed_{user_in.password}",
        "is_active": True,
    }
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user


def get_user_by_id(user_id: int) -> dict | None:
    for user in _fake_users_db:
        if user["id"] == user_id:
            return user
    return None

app/routers/users.py

from fastapi import APIRouter, HTTPException, status

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, get_user_by_id

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate) -> UserOut:
    return create_user(user_in)


@router.get("/{user_id}", response_model=UserOut)
def get_user(user_id: int):
    user = get_user_by_id(user_id)

    if user is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"User with id {user_id} was not found.",
        )

    return user

app/main.py

from fastapi import FastAPI

from app.routers import users

app = FastAPI(title="HTTPException Demo", version="1.0.0")

app.include_router(users.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": "Welcome to HTTPException Demo"}

Detailed Code Explanation

  • get_user_by_id still returns None when nothing matches — the service layer hasn’t changed yet.
  • The real change is in the router: raise HTTPException(status_code=..., detail=...). As soon as this line runs, FastAPI immediately stops executing the rest of the function and builds a proper JSON error response: {"detail": "User with id 999 was not found."} with the status code you specified.
  • Compare this to Chapter 6’s Program 4: no JSONResponse import, no manual content={"detail": ...} dictionary. The code reads much closer to plain English: “if there’s no user, raise a 404.”
  • HTTPException is generic on purpose — it doesn’t know anything about “users” or “orders.” For domain-specific errors, we build our own exceptions next.

Execution & Testing

uv run fastapi dev app/main.py

Open http://127.0.0.1:8000/docs, register a user via POST /users/register, then call GET /users/{user_id} with a non-existent ID (e.g. 999). Confirm you get a 404 with a clear detail message — built entirely by HTTPException, with no manual response code in the router.


Program 2: Custom Exception Classes + a Global Handler

Program Objective

Build a small, standalone FastAPI project with custom exceptions — UserNotFoundError and UsernameAlreadyExistsError — that describe what went wrong in business terms, and global handlers registered once in main.py that translate them into proper HTTP responses everywhere in the app.

Step-by-Step Implementation

Step 1: Create the project

uv init custom-exceptions-demo
cd custom-exceptions-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app/core app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
custom-exceptions-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   └── exceptions.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       └── users.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/core/exceptions.py

class UserNotFoundError(Exception):
    """Raised when a requested user does not exist."""

    def __init__(self, user_id: int):
        self.user_id = user_id
        super().__init__(f"User with id {user_id} was not found.")


class UsernameAlreadyExistsError(Exception):
    """Raised when trying to register a username that's already taken."""

    def __init__(self, username: str):
        self.username = username
        super().__init__(f"Username '{username}' is already taken.")

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

app/services/user_service.py

from app.core.exceptions import UserNotFoundError, UsernameAlreadyExistsError
from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id

    if any(u["username"] == user_in.username for u in _fake_users_db):
        raise UsernameAlreadyExistsError(user_in.username)

    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": f"hashed_{user_in.password}",
        "is_active": True,
    }
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user


def get_user_by_id(user_id: int) -> dict:
    for user in _fake_users_db:
        if user["id"] == user_id:
            return user
    raise UserNotFoundError(user_id)

app/routers/users.py

from fastapi import APIRouter, status

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, get_user_by_id

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate) -> UserOut:
    return create_user(user_in)


@router.get("/{user_id}", response_model=UserOut)
def get_user(user_id: int):
    return get_user_by_id(user_id)

app/main.py

from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse

from app.core.exceptions import UserNotFoundError, UsernameAlreadyExistsError
from app.routers import users

app = FastAPI(title="Custom Exceptions Demo", version="1.0.0")

app.include_router(users.router)


@app.exception_handler(UserNotFoundError)
def user_not_found_handler(request: Request, exc: UserNotFoundError):
    return JSONResponse(
        status_code=status.HTTP_404_NOT_FOUND,
        content={"error_code": "USER_NOT_FOUND", "detail": str(exc)},
    )


@app.exception_handler(UsernameAlreadyExistsError)
def username_exists_handler(request: Request, exc: UsernameAlreadyExistsError):
    return JSONResponse(
        status_code=status.HTTP_409_CONFLICT,
        content={"error_code": "USERNAME_TAKEN", "detail": str(exc)},
    )


@app.get("/", tags=["Root"])
def read_root():
    return {"message": "Welcome to Custom Exceptions Demo"}

Detailed Code Explanation

  • app/core/exceptions.py defines two plain Python exceptions — nothing FastAPI-specific. Your service layer shouldn’t need to know anything about HTTP status codes or JSON; it just describes what went wrong, in its own vocabulary. Storing self.user_id / self.username lets a handler build a detailed message later if needed.
  • user_service.py no longer returns None for a missing user — it raises UserNotFoundError. Callers can no longer accidentally forget to check for None and pass a missing user further down the pipeline. create_user also now checks for duplicate usernames and raises UsernameAlreadyExistsError.
  • routers/users.py is now refreshingly simple — it just calls the service function and returns the result. All error decisions have moved out of the router entirely.
  • In main.py, @app.exception_handler(UserNotFoundError) registers a function that runs automatically, anywhere in the app, whenever a UserNotFoundError is raised — whether in a router, a service function, or three layers deep in a dependency. The handler always receives request and exc as its two arguments — a fixed signature FastAPI expects.
  • str(exc) reuses the message built in the exception’s __init__ — no need to duplicate it.
  • We used 409 Conflict for UsernameAlreadyExistsError — the request is well-formed, it’s just in conflict with existing data, which is a more accurate status code than 400.
  • Every response includes an error_code field, so frontend teams can write if (error.error_code === "USER_NOT_FOUND") instead of fragile string-matching on the message.
  • This is the payoff: if you need to change the error response shape tomorrow, you edit these two handler functions — not a hundred scattered routes.

Execution & Testing

uv run fastapi dev app/main.py
  1. Call POST /users/register twice with the same username. Confirm the second call returns 409 with error_code: "USERNAME_TAKEN".
  2. Call GET /users/{user_id} with a non-existent ID. Confirm you get 404 with error_code: "USER_NOT_FOUND".

Program 3: Customizing Validation Errors + a Catch-All Handler

Program Objective

Build a small, standalone FastAPI project that overrides FastAPI’s default 422 validation error format into a consistent error_code / detail shape, and adds a catch-all handler so unexpected bugs return a safe, generic 500 response instead of a raw traceback.

Step-by-Step Implementation

Step 1: Create the project

uv init full-error-handling-demo
cd full-error-handling-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app/core app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
full-error-handling-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   └── exceptions.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       └── users.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/core/exceptions.py

class UserNotFoundError(Exception):
    """Raised when a requested user does not exist."""

    def __init__(self, user_id: int):
        self.user_id = user_id
        super().__init__(f"User with id {user_id} was not found.")


class UsernameAlreadyExistsError(Exception):
    """Raised when trying to register a username that's already taken."""

    def __init__(self, username: str):
        self.username = username
        super().__init__(f"Username '{username}' is already taken.")

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

app/services/user_service.py

from app.core.exceptions import UserNotFoundError, UsernameAlreadyExistsError
from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id

    if any(u["username"] == user_in.username for u in _fake_users_db):
        raise UsernameAlreadyExistsError(user_in.username)

    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": f"hashed_{user_in.password}",
        "is_active": True,
    }
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user


def get_user_by_id(user_id: int) -> dict:
    for user in _fake_users_db:
        if user["id"] == user_id:
            return user
    raise UserNotFoundError(user_id)

app/routers/users.py

from fastapi import APIRouter, status

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, get_user_by_id

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate) -> UserOut:
    return create_user(user_in)


@router.get("/{user_id}", response_model=UserOut)
def get_user(user_id: int):
    return get_user_by_id(user_id)

app/main.py

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

from app.core.exceptions import UserNotFoundError, UsernameAlreadyExistsError
from app.routers import users

app = FastAPI(title="Full Error Handling Demo", version="1.0.0")

app.include_router(users.router)


@app.exception_handler(UserNotFoundError)
def user_not_found_handler(request: Request, exc: UserNotFoundError):
    return JSONResponse(
        status_code=status.HTTP_404_NOT_FOUND,
        content={"error_code": "USER_NOT_FOUND", "detail": str(exc)},
    )


@app.exception_handler(UsernameAlreadyExistsError)
def username_exists_handler(request: Request, exc: UsernameAlreadyExistsError):
    return JSONResponse(
        status_code=status.HTTP_409_CONFLICT,
        content={"error_code": "USERNAME_TAKEN", "detail": str(exc)},
    )


@app.exception_handler(RequestValidationError)
def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={"error_code": "VALIDATION_ERROR", "detail": exc.errors()},
    )


@app.exception_handler(Exception)
def unhandled_exception_handler(request: Request, exc: Exception):
    # Log the real error internally so your team can investigate.
    print(f"Unhandled error: {exc!r}")

    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={
            "error_code": "INTERNAL_SERVER_ERROR",
            "detail": "Something went wrong on our end. Please try again later.",
        },
    )


@app.get("/", tags=["Root"])
def read_root():
    return {"message": "Welcome to Full Error Handling Demo"}


@app.get("/boom", tags=["Root"])
def boom():
    # Deliberately triggers an unhandled error, to test the catch-all handler.
    return 1 / 0

Detailed Code Explanation

  • RequestValidationError is the exception FastAPI raises internally whenever incoming data fails Pydantic validation. By handling it ourselves, we reshape the response into our own error_code / detail format, matching the other error responses in the app.
  • exc.errors() returns Pydantic’s detailed list of exactly which fields failed and why — genuinely useful for debugging, so we keep it, just under our own response shape.
  • @app.exception_handler(Exception) is a catch-all — it matches any exception not already handled by a more specific handler. This is your safety net against bugs you didn’t anticipate.
  • We print() the real error internally (in a real project, you’d use proper logging), but return a generic, safe message to the client. Never expose raw stack traces, file paths, or internal exception messages — that information helps attackers, not users.
  • GET /boom is a deliberately broken endpoint (1 / 0 raises ZeroDivisionError) added purely so you can test the catch-all handler. Remove it once you’ve confirmed it works, or keep it around as a quick sanity check during development.

⚠️ Note: FastAPI matches handlers by exception type, not by decorator order, so the order these handlers appear in the file doesn’t matter functionally. FastAPI also handles HTTPException separately and safely by default, so your Exception catch-all won’t accidentally swallow it.

Execution & Testing

uv run fastapi dev app/main.py
  1. Validation handler: Call POST /users/register with a password shorter than 8 characters. Confirm the 422 response now follows the custom error_code / detail shape.
  2. Catch-all handler: Call GET /boom. Confirm you get a clean, generic 500 response — not a raw traceback — and check your terminal to see the real error was still logged internally.
  3. Re-run the checks from Program 2 (409 for duplicate username, 404 for missing user) to confirm everything still works together.

Tips & Instructions for Developers

  • ✅ Keep your service layer free of HTTPException — raise plain, custom exceptions there, and let handlers registered in main.py translate them into HTTP responses.
  • ✅ Give every custom exception a clear, descriptive name (UserNotFoundError, not Error1).
  • ✅ Add an error_code field to your error responses — frontend teams won’t have to string-match human-readable messages.
  • ✅ Always include a catch-all Exception handler in production apps — an unhandled bug should never leak a stack trace to a client.
  • ✅ Log the real error internally in your catch-all handler, even while returning a generic message externally.
  • ❌ Don’t put HTTP decisions (“is this a 404 or a 409?”) inside your service functions — that belongs in the exception handler, not the exception itself.
  • ❌ Don’t reuse Python’s built-in exceptions (like ValueError) for domain errors if you can avoid it — a custom exception class is easier to catch precisely and document.

Official FastAPI Documentation References


Theory Questions

  1. What’s the core difference between manually returning a JSONResponse for an error and raising an exception?
  2. Why should custom exceptions like UserNotFoundError avoid importing anything from FastAPI?
  3. What’s the purpose of registering a handler for Exception itself, and why should it always return a generic message?
  4. Why did we choose 409 Conflict instead of 400 Bad Request for a duplicate username?
  5. What problem does adding an error_code field to error responses solve for frontend developers?

Practical Assignment

  1. New custom exception: Starting from Program 3’s project, create an InvalidUserDataError exception and a matching global handler that returns 400 Bad Request. Trigger it with a new validation rule of your choice (e.g., reject usernames that are only numbers).
  2. DELETE with exceptions: Add a DELETE /users/{user_id} endpoint to Program 3’s project. Have the service layer raise UserNotFoundError if the user doesn’t exist, instead of manually building a JSONResponse.
  3. Trace the flow: Add a print() statement inside UserNotFoundError.__init__ and another inside user_not_found_handler. Call GET /users/{user_id} with a missing ID and observe your terminal — write down, in your own words, the exact order these two functions execute in, and why.

Summary

In this chapter, you built three complete, runnable FastAPI projects that each taught you one layer of proper error handling:

  • Replacing manual JSONResponse error blocks with HTTPException
  • Designing custom, domain-specific exception classes and registering global handlers for them
  • Overriding FastAPI’s default validation error format and safely catching unexpected errors with a catch-all handler
Scroll to Top