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_idstill returnsNonewhen 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
JSONResponseimport, no manualcontent={"detail": ...}dictionary. The code reads much closer to plain English: “if there’s no user, raise a 404.” HTTPExceptionis 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.pydefines 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. Storingself.user_id/self.usernamelets a handler build a detailed message later if needed.user_service.pyno longer returnsNonefor a missing user — it raisesUserNotFoundError. Callers can no longer accidentally forget to check forNoneand pass a missing user further down the pipeline.create_useralso now checks for duplicate usernames and raisesUsernameAlreadyExistsError.routers/users.pyis 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 aUserNotFoundErroris raised — whether in a router, a service function, or three layers deep in a dependency. The handler always receivesrequestandexcas 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 ConflictforUsernameAlreadyExistsError— the request is well-formed, it’s just in conflict with existing data, which is a more accurate status code than400. - Every response includes an
error_codefield, so frontend teams can writeif (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
- Call
POST /users/registertwice with the same username. Confirm the second call returns409witherror_code: "USERNAME_TAKEN". - Call
GET /users/{user_id}with a non-existent ID. Confirm you get404witherror_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
RequestValidationErroris the exception FastAPI raises internally whenever incoming data fails Pydantic validation. By handling it ourselves, we reshape the response into our ownerror_code/detailformat, 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 /boomis a deliberately broken endpoint (1 / 0raisesZeroDivisionError) 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
HTTPExceptionseparately and safely by default, so yourExceptioncatch-all won’t accidentally swallow it.
Execution & Testing
uv run fastapi dev app/main.py
- Validation handler: Call
POST /users/registerwith a password shorter than 8 characters. Confirm the422response now follows the customerror_code/detailshape. - Catch-all handler: Call
GET /boom. Confirm you get a clean, generic500response — not a raw traceback — and check your terminal to see the real error was still logged internally. - Re-run the checks from Program 2 (
409for duplicate username,404for 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 inmain.pytranslate them into HTTP responses. - ✅ Give every custom exception a clear, descriptive name (
UserNotFoundError, notError1). - ✅ Add an
error_codefield to your error responses — frontend teams won’t have to string-match human-readable messages. - ✅ Always include a catch-all
Exceptionhandler 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
- Handling Errors
- HTTPException
- Install Custom Exception Handlers
- Override the HTTPException Error Handler
- Override the RequestValidationError Handler
- Reuse FastAPI’s Exception Handlers
Theory Questions
- What’s the core difference between manually returning a
JSONResponsefor an error and raising an exception? - Why should custom exceptions like
UserNotFoundErroravoid importing anything from FastAPI? - What’s the purpose of registering a handler for
Exceptionitself, and why should it always return a generic message? - Why did we choose
409 Conflictinstead of400 Bad Requestfor a duplicate username? - What problem does adding an
error_codefield to error responses solve for frontend developers?
Practical Assignment
- New custom exception: Starting from Program 3’s project, create an
InvalidUserDataErrorexception and a matching global handler that returns400 Bad Request. Trigger it with a new validation rule of your choice (e.g., reject usernames that are only numbers). - DELETE with exceptions: Add a
DELETE /users/{user_id}endpoint to Program 3’s project. Have the service layer raiseUserNotFoundErrorif the user doesn’t exist, instead of manually building aJSONResponse. - Trace the flow: Add a
print()statement insideUserNotFoundError.__init__and another insideuser_not_found_handler. CallGET /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
JSONResponseerror blocks withHTTPException - 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
