FastAPI

FastAPI Error Handling (Custom Exceptions & Global Handlers)

Thirdy Gayares
12 min read

šŸŽÆ What You Will Learn

Every API fails sometimes — bad input, missing records, downstream errors. The difference between a hobby project and a production API is how consistently it fails. This tutorial gives your FastAPI app one predictable error shape your frontend can always trust.

  • How FastAPI handles errors by default (and where it leaks)
  • HTTPException and when to raise it
  • A single, consistent error response schema
  • Custom domain exceptions (UserNotFound, InsufficientBalance)
  • Global handlers with @app.exception_handler
  • Overriding messy 422 validation errors
  • A safe catch-all 500 handler that logs but never leaks internals

Prerequisites: Python 3.11+ and a basic FastAPI app. If Depends() is new to you, read fastapi-dependency-injection first — we reuse a dependency here.

fastapi-errors/
ā”œā”€ā”€ requirements.txt
└── app/
    ā”œā”€ā”€ __init__.py
    ā”œā”€ā”€ main.py         # routes + handler registration
    ā”œā”€ā”€ exceptions.py   # custom exception classes
    ā”œā”€ā”€ handlers.py     # global exception handlers
    └── schemas.py      # the ErrorResponse model

The Mental Model: Errors Are Just Responses

In FastAPI, an error is not a crash — it's a response with a non-2xx status code and a JSON body. Your job is to make that body predictable: the same shape whether it's a 400, 404, or 500, so your frontend can parse it with one function.

StatusMeaningExample
400Bad request (client sent something wrong)Invalid pagination
401 / 403Not authenticated / not allowedMissing token, not admin
404Resource not foundUser id doesn't exist
409ConflictEmail already registered
422Validation error (auto from Pydantic)Missing required field
500Unexpected server errorUncaught bug / DB down
šŸ’”
Ang importante dito: use 4xx for "the client did something wrong" and 5xx for "we did something wrong." Never return 200 with an error field inside — status codes exist for a reason.

The Problem: Inconsistent Errors (See It First)

Here's a naive app. Watch how each failure returns a different shape:

app/main.py (naive — inconsistent)
from fastapi import FastAPI, HTTPException

app = FastAPI()

USERS = {1: {"id": 1, "username": "thirdy"}}


@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = USERS.get(user_id)
    if not user:
        # returns: {"detail": "Not found"}
        raise HTTPException(status_code=404, detail="Not found")
    return user


@app.get("/boom")
def boom():
    value = 1 / 0   # returns a 500 with a full traceback in dev 😱
    return value
GET /users/999   -> {"detail":"Not found"}
GET /boom        -> 500 Internal Server Error (traceback leaked in logs/response)
POST /users {}   -> {"detail":[{"type":"missing","loc":["body","username"], ...}]}
āš ļø
Why this hurts: three endpoints, three different JSON shapes. Your frontend needs three parsers, and the /boom traceback can leak file paths, SQL, and secrets. We'll fix all of this.

HTTPException: The Built-In Way

HTTPException is FastAPI's built-in way to stop a request and return an error. You can set the status code, a detail message, and even custom headers.

app/main.py
from fastapi import FastAPI, HTTPException, status

app = FastAPI(title="FastAPI Errors", version="0.1.0")

USERS = {1: {"id": 1, "username": "thirdy"}}


@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = USERS.get(user_id)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"User {user_id} not found",
        )
    return user
šŸ’”
Use the status constants (status.HTTP_404_NOT_FOUND) instead of raw numbers. It reads better and prevents typos like 440.

HTTPException is perfect for quick cases. But two problems remain: the response shape is just {"detail": "..."}, and raising HTTP concerns deep inside business logic couples your service layer to the web framework. Next we fix both.

A Consistent Error Schema

Decide on one error shape for the whole API. A good, frontend-friendly shape has a stable machine code, a human message, and optional details:

app/schemas.py
from pydantic import BaseModel


class ErrorResponse(BaseModel):
    error: str            # stable machine code, e.g. "user_not_found"
    message: str          # human-readable message for logs/UI
    details: list[str] | None = None   # optional, e.g. field errors

Every error the API returns will look like this, no matter which endpoint or status code:

{
  "error": "user_not_found",
  "message": "User 999 not found",
  "details": null
}
āœ…
Frontend win: the client writes one handler — read error to branch logic, show message to the user. Same shape for 400, 404, 409, and 500.

Custom Domain Exceptions

Instead of raising HTTPException in your business logic, raise domain exceptions that describe what went wrong. Your service code stays clean and framework-free; a handler maps them to HTTP later.

app/exceptions.py
class AppError(Exception):
    """Base class for all known application errors."""

    status_code: int = 400
    error_code: str = "app_error"

    def __init__(self, message: str, details: list[str] | None = None) -> None:
        self.message = message
        self.details = details
        super().__init__(message)


class UserNotFound(AppError):
    status_code = 404
    error_code = "user_not_found"


class EmailAlreadyExists(AppError):
    status_code = 409
    error_code = "email_already_exists"


class InsufficientBalance(AppError):
    status_code = 400
    error_code = "insufficient_balance"

Now the business logic reads like the domain, not like HTTP plumbing:

app/main.py
from app.exceptions import UserNotFound

USERS = {1: {"id": 1, "username": "thirdy"}}


@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = USERS.get(user_id)
    if user is None:
        raise UserNotFound(f"User {user_id} not found")
    return user
šŸ’”
Why this is a senior habit: your service functions can be reused from a CLI, a worker, or tests — they don't know or care about HTTP. Only the web layer translates errors into status codes.

Global Exception Handlers

A global handler catches an exception type anywhere in the app and turns it into your ErrorResponse. Register one handler for the whole AppError family:

app/handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from app.exceptions import AppError
from app.schemas import ErrorResponse


async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    payload = ErrorResponse(
        error=exc.error_code,
        message=exc.message,
        details=exc.details,
    )
    return JSONResponse(status_code=exc.status_code, content=payload.model_dump())


def register_error_handlers(app: FastAPI) -> None:
    app.add_exception_handler(AppError, app_error_handler)
app/main.py
from fastapi import FastAPI

from app.exceptions import UserNotFound
from app.handlers import register_error_handlers

app = FastAPI(title="FastAPI Errors", version="0.1.0")
register_error_handlers(app)

USERS = {1: {"id": 1, "username": "thirdy"}}


@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = USERS.get(user_id)
    if user is None:
        raise UserNotFound(f"User {user_id} not found")
    return user
šŸ’”
Registering the handler for the base AppError automatically covers every subclass (UserNotFound, EmailAlreadyExists, …). Add a new exception type and it just works — no new handler needed.

Overriding Validation Errors (422)

When request data fails Pydantic validation, FastAPI raises RequestValidationError and returns a verbose default body. Override it so validation errors match your schema too:

app/handlers.py
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi import Request, status

from app.schemas import ErrorResponse


async def validation_error_handler(
    request: Request, exc: RequestValidationError
) -> JSONResponse:
    details = [
        f"{'.'.join(str(p) for p in err['loc'][1:])}: {err['msg']}"
        for err in exc.errors()
    ]
    payload = ErrorResponse(
        error="validation_error",
        message="One or more fields are invalid.",
        details=details,
    )
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content=payload.model_dump(),
    )

Register it alongside the others:

app/handlers.py
from fastapi.exceptions import RequestValidationError


def register_error_handlers(app: FastAPI) -> None:
    app.add_exception_handler(AppError, app_error_handler)
    app.add_exception_handler(RequestValidationError, validation_error_handler)
    app.add_exception_handler(Exception, unhandled_error_handler)  # see next section
POST /users  { }   (missing username)

{
  "error": "validation_error",
  "message": "One or more fields are invalid.",
  "details": ["username: Field required"]
}

The Safe Catch-All 500 Handler

The most important one for production: catch any unhandled exception, log the full traceback for yourself, but return a generic message to the client. Never leak stack traces.

app/handlers.py
import logging

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

from app.schemas import ErrorResponse

logger = logging.getLogger("app.errors")


async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
    # Log everything for us; the client sees nothing sensitive.
    logger.exception("Unhandled error on %s %s", request.method, request.url.path)
    payload = ErrorResponse(
        error="internal_server_error",
        message="Something went wrong. Please try again later.",
    )
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content=payload.model_dump(),
    )
āš ļø
Production rule: never send the raw exception message or traceback to the client. It can expose file paths, SQL, env values, and library versions attackers love. Log it server-side; return a generic message.
šŸ’”
logger.exception(...) automatically includes the traceback. Pair it with structured logging (see fastapi-logging in the roadmap) so you can search errors by request path.

Try It (curl + Swagger)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
1Run the app
run.sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Hit the error paths — same shape every time
# 404 domain error
$ curl -s http://127.0.0.1:8000/users/999
{"error":"user_not_found","message":"User 999 not found","details":null}

# 422 validation error (missing body field)
$ curl -s -X POST http://127.0.0.1:8000/users -H "Content-Type: application/json" -d '{}'
{"error":"validation_error","message":"One or more fields are invalid.","details":["username: Field required"]}

# 500 unexpected error — generic message, traceback stays in the logs
$ curl -s http://127.0.0.1:8000/boom
{"error":"internal_server_error","message":"Something went wrong. Please try again later."}
āœ…
You did it: 404, 422, and 500 now share the exact same ErrorResponse shape. One frontend parser handles them all, and no internal details leak.

Common Mistakes (and Fixes)

MistakeSymptomFix
Returning 200 with an error fieldFrontend/monitoring can't tell success from failureUse the right 4xx/5xx status code
Raising HTTPException deep in business logicService layer coupled to the web frameworkRaise domain exceptions; map to HTTP in a handler
Sending the raw traceback to the clientLeaks paths, SQL, secretsLog server-side, return a generic 500 message
Forgetting to register the Exception catch-allUnhandled errors bypass your schemaAdd add_exception_handler(Exception, ...)
Using raise HTTPException with no messageFrontend shows a blank/vague errorAlways include a clear detail/message
āš ļø
Gotcha: the order of registration doesn't matter, but specificity does. FastAPI matches the most specific registered exception type first, so AppError is used before the generic Exception catch-all.

What's Next

āœ…
šŸš€ Recommended next reads:
  • fastapi-dependency-injection — raise these exceptions from inside dependencies (auth, DB lookups)
  • fastapi-jwt — return clean 401/403 errors with this same schema
  • fastapi-crud-patterns — UserNotFound / EmailAlreadyExists in real CRUD
  • fastapi-logging — turn logger.exception into searchable structured logs

Recap: pick one ErrorResponse shape, raise domain exceptions in your logic, and map them to HTTP with global handlers. Add a validation override and a safe catch-all, and your API fails the same way every time — which is exactly what a good API should do.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.