FastAPI generates an OpenAPI schema for you, but by default it says almost nothing about how your API fails. This guide builds that part of the schema up step by step: one error format and a schema that lists exactly which errors each route can return.

I've been designing and building APIs with FastAPI for over three years, and this guide is what that work taught me about errors.

Every API has to tell a client that something went wrong.

FastAPI gives you HTTPException for that, and it works.

@app.get("/todos/{todo_id}") async def get_todo(todo_id: int) -> Todo: if todo_id not in todos: raise HTTPException(status_code=404, detail="Todo not found") return todos[todo_id] 

Ask for a todo that isn't there and you get a 404 with this body:

{ "detail": "Todo not found" } 

Now send a body that fails validation to the same API, say a todo with an empty title. You get this:

{ "detail": [ { "type": "string_too_short", "loc": ["body", "title"], "msg": "String should have at least 1 character", "input": "", "ctx": { "min_length": 1 } } ] } 

Two errors from one API, and they have almost nothing in common.

In the first, detail is a string. In the second it is a list of objects.

But the response bodies are the smaller problem. The bigger one is that your openapi.json says almost nothing about any of it.

That matters because your schema is what everyone else builds against. It is what the docs page renders, what a client generator reads, and what another team checks when they want to know how your API fails. If it does not describe your errors, nobody can handle them properly, and they will end up matching on English sentences instead.

Where this ends up

Before building anything, here is the difference this makes to the docs page.

This is POST /todos as FastAPI documents it today, in ReDoc, the docs page FastAPI serves at /redoc.

POST /todos in ReDoc, documenting a 201 and FastAPI's 422 and nothing else

And this is the same route at the end of this guide.

The same route documenting a 400, a 422 and a 500, with the errors behind each

Same two routes, and the same request and response models. The only thing that changed is how the errors are handled.

Two things in the second picture are worth looking at now, because most of this guide exists to produce them.

The 400 lists two errors, with a sentence saying what to do about each. One status code, two errors, told apart by name.

And the samples panel on the right has a dropdown, currently set to too_many_todos. That is a reader picking one error out of the two that share a status code, which is only possible because the schema says how to tell them apart.

Swagger UI, the docs page FastAPI serves at /docs, gets the same dropdown by the end.

That is what the rest of this guide builds, in fifteen steps.

The example

Everything below is built on a tiny todo API with two routes. This is all of it:

main.py

from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field class TodoCreate(BaseModel): title: str = Field(min_length=1, examples=["Walk the dog"]) class Todo(BaseModel): id: int = Field(examples=[3]) title: str = Field(examples=["Walk the dog"]) MAX_TODOS = 3 todos = {1: Todo(id=1, title="Buy milk"), 2: Todo(id=2, title="Call mom")} app = FastAPI() @app.post("/todos", status_code=201) async def create_todo(data: TodoCreate) -> Todo: if any(todo.title == data.title for todo in todos.values()): raise HTTPException(status_code=400, detail="Title already taken") if len(todos) >= MAX_TODOS: raise HTTPException(status_code=400, detail="Todo limit reached") todo = Todo(id=len(todos) + 1, title=data.title) todos[todo.id] = todo return todo @app.get("/todos/{todo_id}") async def get_todo(todo_id: int) -> Todo: if todo_id not in todos: raise HTTPException(status_code=404, detail="Todo not found") return todos[todo_id] 

POST /todos adds a todo, and GET /todos/{todo_id} reads one back. They can fail in a few ways. The todo doesn't exist. Its title is taken, or you already have as many todos as you're allowed. The request is invalid. Or something broke on our side.

The code is on GitHub at nimaxin/fastapi-error-responses, with a folder for every step that changes it. Each folder is a complete app, so you can run the API as it stands after any step:

git clone https://github.com/nimaxin/fastapi-error-responses cd fastapi-error-responses uv run fastapi dev steps/step05/main.py 

Step 1. Give the client something to branch on

What is missing

Look again at what a client gets when it asks for a todo that isn't there.

{ "detail": "Todo not found" } 

There is nothing in there a program can act on.

It gets worse when two errors share a status code. "Title already taken" and "Todo limit reached" are both 400s, so the only way for a client to tell them apart is to compare the English sentence. And one day you will change that wording, because it is written for people to read.

So give it a short string that never changes instead.

A quick fix

HTTPException takes any JSON-able value as detail, not only a string. So you can do this right now, with nothing else in place.

main.py

 if len(todos) >= MAX_TODOS: raise HTTPException( status_code=400, detail={"code": "too_many_todos", "message": "Todo limit reached"}, ) 

The resulting response

{ "detail": { "code": "too_many_todos", "message": "Todo limit reached" } } 

That is real progress. A client can look at detail.code, see too_many_todos, and never read the sentence.

Two things are wrong with it, and the next two steps deal with one each.

The body is nested inside detail, which is FastAPI's field and not yours.

And "too_many_todos" is a string you typed by hand, inside a route, with nothing checking it.

The other two errors get the same change. The code so far: steps/step01

Step 2. Own the shape of the response

Why detail has to go

You want three fields at the top level, not two of yours wrapped in one of FastAPI's.

And there is a second reason. Send a bad body to that same route and you still get pydantic's nested loc array, which looks nothing like what you just built.

Both are the same problem. FastAPI is choosing the shape of your error responses, and you want to choose it.

You do that by registering an exception handler. And a handler needs an exception class of its own to be registered against.

Write the exception class

errors.py

class BaseHTTPError(Exception): def __init__(self, status_code: int, code: str, message: str) -> None: self.status_code = status_code self.code = code self.message = message super().__init__(message) 

Notice what it does not inherit from.

Warning

Subclassing HTTPException is the obvious move here, and it will bite you.

Registering a handler for a subclass of HTTPException does work, to be clear. Starlette picks the most specific handler you have registered, including when the error is raised from a dependency.

The problem is what happens the day you forget to register it. A subclass of HTTPException falls back to FastAPI's own handler and returns a 400 with {"detail": ...}. Right status, wrong shape, and no warning anywhere. A test that asserts response.status_code == 400 still passes.

Subclass Exception and the same mistake gives you a 500, which you notice immediately.

There is a second reason. It comes up less often, but it is real. Any except HTTPException between the raise and the handler will catch your error, and that includes middleware you did not write.

Write the handler

The handler turns the error into the three fields you want, at the top level.

main.py

async def http_error_handler(request: Request, e: BaseHTTPError) -> JSONResponse: return JSONResponse( {"code": e.code, "message": e.message, "details": {}}, status_code=e.status_code, ) app.add_exception_handler(BaseHTTPError, http_error_handler) 

details is empty for now. Step 5 gives it something to hold.

Raise it

Because it is a plain Python exception, you don't return it. You raise it.

raise BaseHTTPError(400, "too_many_todos", "Todo limit reached") 

The resulting response

{ "code": "too_many_todos", "message": "Todo limit reached", "details": {} } 

Three fields, no wrapper, and the shape is yours.

Note

There is a standard for error bodies: RFC 9457, Problem Details for HTTP APIs. It names the fields type, title, status, detail and instance, and sends them as application/problem+json.

This guide uses code, message and details instead, the shape Stripe, Google and Microsoft each use in some form, because a short code like too_many_todos is easier to read and type than the URI the standard puts in type.

The ideas carry over one to one. code becomes type, message becomes detail, and what this guide keeps in details moves to the top level. Pick one shape and use it everywhere. Everything below applies to either.

The code so far: steps/step02

Step 3. Stop repeating yourself at every raise site

What the last step left behind

Every raise now carries three arguments, and every one of them is the same every time that error happens.

raise BaseHTTPError(400, "duplicate_title", "Title already taken") raise BaseHTTPError(400, "too_many_todos", "Todo limit reached") raise BaseHTTPError(404, "todo_not_found", "Todo not found") 

Which means the status code for "todo limit reached" is written wherever you happen to raise it, and if two places disagree, nothing tells you.

These belong to the error, not to the place it is raised. So put them on a class.

A class variable for each

errors.py

class BaseHTTPError(Exception): status_code: ClassVar[int] code: ClassVar[str] message: ClassVar[str] = "" def __init__(self) -> None: super().__init__(self.message) 

ClassVar because these describe the kind of error, not one instance of it.

A thin layer for the status code

Several errors will share a status code, so write it once.

errors.py

class NotFoundError(BaseHTTPError): status_code = status.HTTP_404_NOT_FOUND class BadRequestError(BaseHTTPError): status_code = status.HTTP_400_BAD_REQUEST 

Tip

You can spend a long time deciding whether a given error is a 409, a 412 or a 422.

Some APIs skip that and answer anything their business rules reject with 400, letting code say which rule it was. Others pick the closest status: 409 Conflict, 422 Unprocessable Content, or 402, which Stripe sends for a declined card. Both work, because a client branches on code either way. This guide uses 400.

What you shouldn't fold into 400 are the statuses other software acts on. Auth middleware refreshes tokens on 401, and 403 tells it the credentials are fine but not allowed, so a refresh won't help. Clients and proxies back off on 429, where a 400 tells them not to retry at all. Caches and monitoring treat 404 differently from other failures. And the line between 4xx and 5xx is what decides whether retrying is worth trying.

So the status code is for the transport and for general infrastructure. code is for your application.

Note

Some statuses need a header as well. RFC 9110, the HTTP standard, says a 401 must send WWW-Authenticate with a way to authenticate, and a 429 usually sends Retry-After. If your API returns those, give the base class a headers class variable, set it on the errors that need it, and send it from the handler.

Then one class per error

Each real error is now three lines.

errors.py

class TodoNotFoundError(NotFoundError): code = "todo_not_found" message = "Todo not found" class DuplicateTitleError(BadRequestError): code = "duplicate_title" message = "Title already taken" class TooManyTodosError(BadRequestError): code = "too_many_todos" message = "Todo limit reached" 

The raise gets shorter

raise TooManyTodosError 

And the response is the same as before, because the handler reads the same three attributes. It just does not care any more whether they came from an argument or a class.

Note

The message lives on the class, not at the place you raise it. So every "Todo not found" in your whole API is byte for byte identical.

This matters for security. If the 404 for a todo that never existed read differently from the 404 for somebody else's todo, then anyone could use your API to work out which todo ids are real.

The code so far: steps/step03

Step 4. Name the codes in one place

Three hand-typed strings

Look at what you just wrote. Three classes, three string literals, and nothing connecting them.

Nothing stops you typing "too_many_todo". Nothing stops two errors claiming the same code. And when you want to know every code your API can return, the only way to find out is to read every file.

An enum fixes all three.

errors.py

from enum import StrEnum, auto class ErrorCode(StrEnum): todo_not_found = auto() duplicate_title = auto() too_many_todos = auto() 

StrEnum with auto() gives each member its own lowercase name as its value. So ErrorCode.too_many_todos is the string "too_many_todos", and you never write that string twice.

Then the classes point at it, and a typo is an AttributeError at import time instead of a wrong string in a response body. The base class declares code as ClassVar[ErrorCode] now.

errors.py

class TooManyTodosError(BadRequestError): code = ErrorCode.too_many_todos message = "Todo limit reached" 

Tip

Name your codes for what the caller was trying to do, never for how you do it inside.

too_many_todos is good. todo_table_full is not, because one day you will change how todos are stored and that code will still be there.

These strings are public API. Once a client ships a branch on them, you are keeping them forever.

The code so far: steps/step04

Step 5. The failures you did not write

Your errors are consistent now, and nothing else is

Send a bad body and you still get pydantic's nested array. Ask for a path that doesn't exist and you get FastAPI's {"detail": "Not Found"}. Cause a crash and you get whatever Starlette decides.

All three are responses your API sends, so all three need the same treatment. Start with a code for each:

errors.py

class ErrorCode(StrEnum): ... validation_error = auto() http_error = auto() internal_server_error = auto() 

The validation error and the crash get classes like everything else.

errors.py

class ValidationError(BaseHTTPError): status_code = status.HTTP_422_UNPROCESSABLE_CONTENT code = ErrorCode.validation_error message = "Invalid request data" class InternalServerError(BaseHTTPError): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR code = ErrorCode.internal_server_error message = "An unexpected error occurred" 

You will never raise these two yourself. The handlers build them.

And a validation error is the first one with something to put in details, so every error gets a details of its own now, empty until something fills it:

errors.py

 def __init__(self) -> None: self.details: dict[str, Any] = {} super().__init__(self.message) 

Validation errors

When a request is invalid, FastAPI raises RequestValidationError internally, and you can handle it like anything else.

Each problem pydantic reports has a loc, a type and a msg. The loc is a tuple like ("body", "title"). Its first element says which part of the request had the problem, and the rest say where in it.

So turn each problem into a small object with those parts named:

main.py

def field_path(err: dict[str, Any]) -> str: # For broken JSON, FastAPI puts a character position where the field would be.  if err["type"] == "json_invalid": return "" return ".".join(str(part) for part in err["loc"][1:]) async def validation_error_handler( request: Request, e: RequestValidationError ) -> JSONResponse: error = ValidationError() error.details = { "errors": [ { "location": err["loc"][0], "field": field_path(err), "type": err["type"], "message": err["msg"], } for err in e.errors() ] } return JSONResponse( {"code": error.code, "message": error.message, "details": error.details}, status_code=error.status_code, ) 

Send an empty title and here is what comes back:

{ "code": "validation_error", "message": "Invalid request data", "details": { "errors": [ { "location": "body", "field": "title", "type": "string_too_short", "message": "String should have at least 1 character" } ] } } 

Compare that with the nested array from the top of the article. A frontend can find the problems with each input by field, and pick its own wording, in its own language, from type. It never has to show pydantic's English message, or parse anything.

location says which part of the request was wrong. Ask for /todos/abc, and the problem is in the path:

{ "code": "validation_error", "message": "Invalid request data", "details": { "errors": [ { "location": "path", "field": "todo_id", "type": "int_parsing", "message": "Input should be a valid integer, unable to parse string as an integer" } ] } } 

And field_path takes care of the one loc that is not a path. When the body is not JSON at all, FastAPI puts the character position where the field name would go, so field stays empty:

{ "code": "validation_error", "message": "Invalid request data", "details": { "errors": [ {...
Source:https://dev.to/nimaxin/how-to-document-error-responses-in-fastapi-48a7