šÆ 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)
HTTPExceptionand 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 modelThe 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.
| Status | Meaning | Example |
|---|---|---|
| 400 | Bad request (client sent something wrong) | Invalid pagination |
| 401 / 403 | Not authenticated / not allowed | Missing token, not admin |
| 404 | Resource not found | User id doesn't exist |
| 409 | Conflict | Email already registered |
| 422 | Validation error (auto from Pydantic) | Missing required field |
| 500 | Unexpected server error | Uncaught bug / DB down |
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:
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 valueGET /users/999 -> {"detail":"Not found"}
GET /boom -> 500 Internal Server Error (traceback leaked in logs/response)
POST /users {} -> {"detail":[{"type":"missing","loc":["body","username"], ...}]}/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.
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 userstatus 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:
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 errorsEvery 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
}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.
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:
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 userGlobal 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:
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)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 userAppError 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:
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:
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 sectionPOST /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.
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(),
)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)
fastapi==0.116.1
uvicorn[standard]==0.30.6python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload# 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."}ErrorResponse shape. One frontend parser handles them all, and no internal details leak.Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
| Returning 200 with an error field | Frontend/monitoring can't tell success from failure | Use the right 4xx/5xx status code |
Raising HTTPException deep in business logic | Service layer coupled to the web framework | Raise domain exceptions; map to HTTP in a handler |
| Sending the raw traceback to the client | Leaks paths, SQL, secrets | Log server-side, return a generic 500 message |
Forgetting to register the Exception catch-all | Unhandled errors bypass your schema | Add add_exception_handler(Exception, ...) |
Using raise HTTPException with no message | Frontend shows a blank/vague error | Always include a clear detail/message |
AppError is used before the generic Exception catch-all.What's Next
fastapi-dependency-injectionā raise these exceptions from inside dependencies (auth, DB lookups)fastapi-jwtā return clean401/403errors with this same schemafastapi-crud-patternsāUserNotFound/EmailAlreadyExistsin real CRUDfastapi-loggingā turnlogger.exceptioninto 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.