Updated: October 26, 2026
A phone app calls an API and gets an error. The app wants to show a short, kind message. But the error comes back in a shape the app did not expect, so it shows "undefined" instead.
This happens a lot. FastAPI is easy to work with. Its default errors just do not all look alike.
Below, every error gets one JSON shape, and a few tests check it.
Tested with: Python 3.13.5, FastAPI 0.161.0, Pydantic 2.14.1 and pytest 9.1.1 on Linux. Outputs are pasted from a single run.
Two words first
- Exception: a Python error that stops the normal flow of code.
- Exception handler: a function that catches one kind of exception and turns it into a response.
FastAPI lets you register a handler for each kind of exception. That is the whole trick.
The problem: two shapes by default
Here is a tiny app with no custom handlers. It asks for a missing book, then sends a word where a number belongs.
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
plain = FastAPI()
@plain.get("/books/{book_id}")
def get_book(book_id: int):
raise HTTPException(status_code=404, detail="Book not found")
client = TestClient(plain)
print(client.get("/books/7").json())
print(client.get("/books/abc").json())
{'detail': 'Book not found'}
{'detail': [{'type': 'int_parsing', 'loc': ['path', 'book_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'abc'}]}
In the first error, detail is a string. In the second, detail is a list of objects. Client code now needs two paths just to read an error.
One shape for all errors
Pick a shape and keep it. This post uses a small one:
{"error": {"status": 404, "code": "book_not_found", "message": "No book with id 7."}}
- status repeats the HTTP status, which helps in logs.
- code is a short, stable word. Client code can check it safely.
- message is for humans. You can reword it later without breaking clients.
- details appears only when there is more to say, such as which field failed.
There is also a standard shape, called "Problem Details" (RFC 9457). It uses fields like type, title and detail. It is a good choice for public APIs. The idea in this post is the same either way: one shape, everywhere.
The app with four handlers
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.exceptions import HTTPException as StarletteHTTPException
log = logging.getLogger("api")
app = FastAPI()
class AppError(Exception):
def __init__(self, status: int, code: str, message: str):
self.status = status
self.code = code
self.message = message
def error_body(status, code, message, details=None):
body = {"status": status, "code": code, "message": message}
if details:
body["details"] = details
return {"error": body}
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError):
body = error_body(exc.status, exc.code, exc.message)
return JSONResponse(status_code=exc.status, content=body)
@app.exception_handler(RequestValidationError)
async def handle_bad_input(request: Request, exc: RequestValidationError):
details = []
for err in exc.errors():
field = ".".join(str(part) for part in err["loc"][1:])
details.append({"field": field, "problem": err["msg"]})
body = error_body(422, "invalid_input", "Some fields are not valid.", details)
return JSONResponse(status_code=422, content=body)
@app.exception_handler(StarletteHTTPException)
async def handle_http_error(request: Request, exc: StarletteHTTPException):
codes = {404: "not_found", 405: "method_not_allowed"}
code = codes.get(exc.status_code, "http_error")
body = error_body(exc.status_code, code, str(exc.detail))
return JSONResponse(status_code=exc.status_code, content=body,
headers=exc.headers)
@app.exception_handler(Exception)
async def handle_crash(request: Request, exc: Exception):
log.exception("Unhandled error on %s", request.url.path)
body = error_body(500, "internal_error", "Something went wrong on the server.")
return JSONResponse(status_code=500, content=body)
BOOKS = {1: {"id": 1, "title": "Clean Errors", "pages": 120}}
class NewBook(BaseModel):
title: str = Field(min_length=1)
pages: int = Field(gt=0)
@app.get("/books/{book_id}")
def get_book(book_id: int):
if book_id not in BOOKS:
raise AppError(404, "book_not_found", f"No book with id {book_id}.")
return BOOKS[book_id]
@app.post("/books", status_code=201)
def add_book(book: NewBook):
new_id = max(BOOKS) + 1
BOOKS[new_id] = {"id": new_id, **book.model_dump()}
return BOOKS[new_id]
@app.get("/books/{book_id}/rating")
def get_rating(book_id: int):
scores = [] # bug: no reviews yet, so this list is empty
return {"rating": sum(scores) / len(scores)}
Each handler has one job:
-
AppErroris for errors you raise on purpose, like a missing book. You choose the status and the code. -
RequestValidationErrorfires when input fails the Pydantic model. The handler lists each bad field in plain words. -
StarletteHTTPExceptioncovers errors the framework raises itself, like an unknown URL (404) or a wrong method (405). Register the Starlette class, not only FastAPI's ownHTTPException. Then those framework errors get caught too. -
Exceptionis the safety net for bugs. It logs the full traceback on the server. The client only gets a calm, general message.
Why hide the details of a crash? A traceback can show file paths, library versions and pieces of data. That belongs in your logs, not in a public reply.
See every error in one run
TestClient sends fake requests to the app without starting a server. The option raise_server_exceptions=False makes it return the 500 response instead of raising the bug.
import json
import logging
from fastapi.testclient import TestClient
from app import app
logging.disable(logging.CRITICAL) # keep the demo output short
client = TestClient(app, raise_server_exceptions=False)
for method, path, data in [
("GET", "/books/7", None),
("POST", "/books", {"title": "", "pages": -3}),
("GET", "/nowhere", None),
("GET", "/books/1/rating", None),
]:
r = client.request(method, path, json=data)
print(method, path, "->", r.status_code)
print(json.dumps(r.json(), indent=2))
GET /books/7 -> 404
{
"error": {
"status": 404,
"code": "book_not_found",
"message": "No book with id 7."
}
}
POST /books -> 422
{
"error": {
"status": 422,
"code": "invalid_input",
"message": "Some fields are not valid.",
"details": [
{
"field": "title",
"problem": "String should have at least 1 character"
},
{
"field": "pages",
"problem": "Input should be greater than 0"
}
]
}
}
GET /nowhere -> 404
{
"error": {
"status": 404,
"code": "not_found",
"message": "Not Found"
}
}
GET /books/1/rating -> 500
{
"error": {
"status": 500,
"code": "internal_error",
"message": "Something went wrong on the server."
}
}
Four different failures. One shape. The client code needs just one path now.
Lock it in with tests
Shapes drift over time. Someone adds a new route and raises a plain error. Tests catch that early.
import pytest
from fastapi.testclient import TestClient
from app import app
client = TestClient(app, raise_server_exceptions=False)
def check_shape(response, status, code):
assert response.status_code == status
assert response.headers["content-type"] == "application/json"
error = response.json()["error"]
assert error["status"] == status
assert error["code"] == code
assert error["message"]
return error
def test_missing_book():
check_shape(client.get("/books/7"), 404, "book_not_found")
def test_bad_input_lists_each_field():
r = client.post("/books", json={"title": "", "pages": -3})
error = check_shape(r, 422, "invalid_input")
assert [d["field"] for d in error["details"]] == ["title", "pages"]
def test_wrong_type_in_path():
error = check_shape(client.get("/books/abc"), 422, "invalid_input")
assert error["details"][0]["field"] == "book_id"
@pytest.mark.parametrize("method, path, status, code", [
("GET", "/nowhere", 404, "not_found"),
("DELETE", "/books/1", 405, "method_not_allowed"),
])
def test_framework_errors(method, path, status, code):
check_shape(client.request(method, path), status, code)
def test_crash_hides_details():
error = check_shape(client.get("/books/1/rating"), 500, "internal_error")
assert "ZeroDivision" not in error["message"]
def test_good_request_still_works():
r = client.post("/books", json={"title": "Fast Paths", "pages": 90})
assert r.status_code == 201
assert r.json()["title"] == "Fast Paths"
7 passed in 0.24s
check_shape is the heart of it. Every test runs through it, so every error must pass the same checks. Note the last test. It makes sure the good path still works after all these changes.
Small rules that keep errors useful
-
Keep codes stable. Treat
book_not_foundlike part of the API. Changing it can break a client. - Keep messages short. One sentence. No stack traces, no SQL.
- Use the right status. 404 for missing, 422 for bad input, 500 for bugs.
- Log the 500s. The calm message is for users. The traceback is for you.
-
Add a test for each new error code. It takes one line with
check_shape.
Key points
- FastAPI's default errors come in more than one shape.
- Pick one JSON shape: status, code, message and optional details.
- Register handlers for your own error class, validation errors, Starlette HTTP errors and plain
Exception. - Hide crash details from clients. Keep them in logs.
- Test the shape for every kind of error.
Sources: FastAPI docs: handling errors; FastAPI docs: testing; Starlette docs: exceptions; Starlette docs: test client; RFC 9457: Problem Details for HTTP APIs; Python docs: logging, Logger.exception
This note comes from ByteBridge Talent LLC, an IT staffing company in Grand Island, Nebraska.
Version note: this code ran on FastAPI 0.161.0 and Starlette 1.9.0. Handler names and test client options can shift between major versions. Check the docs for the version in your own project before you copy it.
Top comments (0)