DEV Community

ByteBridge Talent LLC
ByteBridge Talent LLC

Posted on

Clear JSON Errors in FastAPI: One Shape for Every Failure

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())
Enter fullscreen mode Exit fullscreen mode
{'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'}]}
Enter fullscreen mode Exit fullscreen mode

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."}}
Enter fullscreen mode Exit fullscreen mode
  • 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)}
Enter fullscreen mode Exit fullscreen mode

Each handler has one job:

  1. AppError is for errors you raise on purpose, like a missing book. You choose the status and the code.
  2. RequestValidationError fires when input fails the Pydantic model. The handler lists each bad field in plain words.
  3. StarletteHTTPException covers errors the framework raises itself, like an unknown URL (404) or a wrong method (405). Register the Starlette class, not only FastAPI's own HTTPException. Then those framework errors get caught too.
  4. Exception is 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))
Enter fullscreen mode Exit fullscreen mode
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."
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
7 passed in 0.24s
Enter fullscreen mode Exit fullscreen mode

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_found like 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)