Status Codes & Error Handling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. All code runs on Python 3.10 or newer and was executed with TestClient on Python 3.13. An HTTP API communicates success and failure twice: with a numeric status code that machines act on, and with a body that humans and client libraries read. FastAPI gives you three layers for this — the status_code parameter and fastapi.status constants for the success path, exceptions (HTTPException and your own) for the failure path, and exception handlers that turn any exception into the response you want. The page follows the Bookshelf API (books, authors, reviews) through all three, then shows how to document error responses in OpenAPI, how to adopt the standard problem-details format, and how to track down a bad status code in production. Validation of the request itself (the 422 response) is introduced in Path Parameters, Query Parameters and Request Body & Models; this page is where you change what it looks like.

The Big Picture

Every response FastAPI sends falls into one of four paths. Knowing which path produced a response is the first step of any debugging session.

Path Produced by Where you influence it

Success

Your function returns a value; FastAPI serialises it with the status_code of the decorator (default 200)

status_code=, a Response parameter, or returning a Response

Handled error

Your code (or a dependency) raises HTTPException, or any exception for which a handler is registered

raise HTTPException(…​), @app.exception_handler(…​)

Validation error

FastAPI cannot convert or validate the request, so it raises RequestValidationError (422)

A handler for RequestValidationError

Unhandled error

Any other exception escapes your code (a bug), or the response violates its response_model

A handler for Exception (the last resort), logging, and fixing the bug

Starlette provides the machinery underneath: HTTPException, the exception-handling middleware, the status module. FastAPI adds the subclass of HTTPException that accepts any JSON-compatible detail, the RequestValidationError and ResponseValidationError classes, and the default handlers. Pydantic produces the validation error descriptions that end up inside a 422 body.

status_code and fastapi.status

Every path operation decorator (@app.get(), @app.post(), @app.put(), @app.patch(), @app.delete(), and the same decorators on an APIRouter) accepts status_code. The status code is a parameter of the decorator, not of the function. The code is used for the successful response, and it is also written into the OpenAPI document, so Swagger UI and generated clients show 201 rather than 200 for the operation.

The value can be a plain integer, a member of Python’s http.HTTPStatus (an IntEnum), or a constant from fastapi.status. The constants are the same numbers with names that editors can autocomplete; the module comes straight from Starlette and also contains the WebSocket close codes (WS_1008_POLICY_VIOLATION and friends). Prefer the named constants in code that humans read; the number 201 is fine in a quick example, 418 is not.

from http import HTTPStatus

from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class BookIn(BaseModel):
    title: str


@app.post("/books", status_code=status.HTTP_201_CREATED)
def create_book(book: BookIn) -> dict:
    return {"id": 1, "title": book.title}


@app.get("/teapot", status_code=HTTPStatus.IM_A_TEAPOT)
def teapot() -> dict:
    return {"short": "and stout"}


@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int) -> None:
    return None

c = TestClient(app)
r = c.post("/books", json={"title": "Dune"})
print(r.status_code, r.json())
print(c.get("/teapot").status_code)
r = c.delete("/books/1")
print(r.status_code, repr(r.content))
spec = c.get("/openapi.json").json()["paths"]
print(list(spec["/books"]["post"]["responses"]))

Running it prints:

201 {'id': 1, 'title': 'Dune'}
418
204 b''
['201', '422']

The last line shows the OpenAPI effect: the documented success response of the POST is 201, and FastAPI adds 422 automatically because the operation has a body that can fail validation. A route with no declared status_code documents 200. The status_code of the decorator is the default; "Dynamic status through a Response parameter" below shows how to vary it per request.

Status code classes

The first digit tells a client what to do, so choose the class before the number.

Range Meaning In a FastAPI API

1xx

Informational

Handled by the server; you rarely use them directly. Responses with these codes cannot have a body.

2xx

Success

200 (default), 201 created, 202 accepted for later processing, 204 no content.

3xx

Redirection

RedirectResponse (307/308 keep the method, 303 turns a POST into a GET); 304 must not have a body. FastAPI’s own 307 redirect appears when a trailing slash is missing (see the troubleshooting table).

4xx

The client made a mistake

400, 401, 403, 404, 405, 409, 422, 429. Raised with HTTPException or produced by FastAPI itself.

5xx

The server failed

You almost never choose these; they appear when something raises an unhandled exception.

The catalogue of codes is maintained by IANA and defined in RFC 9110; MDN has a readable summary of each code.

Constant names to know

Constant Number Typical use in Bookshelf

status.HTTP_200_OK

200

Read, list, full update returning the resource.

status.HTTP_201_CREATED

201

POST /books created a book.

status.HTTP_202_ACCEPTED

202

Accepted, processed later (for example a cover-image conversion job).

status.HTTP_204_NO_CONTENT

204

DELETE /books/{id} succeeded; nothing to return.

status.HTTP_400_BAD_REQUEST

400

Generic client error not covered by a more precise code.

status.HTTP_401_UNAUTHORIZED

401

Missing or invalid credentials (send WWW-Authenticate).

status.HTTP_403_FORBIDDEN

403

Authenticated but not allowed.

status.HTTP_404_NOT_FOUND

404

The book, author or review does not exist.

status.HTTP_409_CONFLICT

409

The request conflicts with current state (duplicate ISBN).

status.HTTP_410_GONE

410

A resource that existed and was deliberately removed.

status.HTTP_413_CONTENT_TOO_LARGE

413

An upload exceeds the limit.

status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

415

The request Content-Type is not accepted.

status.HTTP_422_UNPROCESSABLE_CONTENT

422

Syntactically fine, semantically invalid; FastAPI’s validation response.

status.HTTP_429_TOO_MANY_REQUESTS

429

Rate limit reached (send Retry-After).

status.HTTP_500_INTERNAL_SERVER_ERROR

500

Unhandled error.

status.HTTP_503_SERVICE_UNAVAILABLE

503

Dependency down, shutting down, overloaded.

The 422 constant was renamed in line with RFC 9110: the installed Starlette 1.7 marks HTTP_422_UNPROCESSABLE_ENTITY as deprecated and emits a DeprecationWarning that tells you to use HTTP_422_UNPROCESSABLE_CONTENT. Both hold 422; use the new name in new code.

201 for Create, 204 for Delete

Two success codes carry most of the weight in a CRUD API.

  • 201 Created tells the client that a new resource exists. By convention the response contains the new resource (so the client learns the generated id) and a Location header with the URL of the new resource.

  • 204 No Content tells the client that the operation succeeded and that there is nothing to send back. A 204 response must not have a body, and FastAPI honours that: whatever your function returns is discarded and no body is written, so the generated OpenAPI document also states that there is no response content. Annotate the function → None so that the type hints tell the truth too. This is the usual answer for DELETE.

The idempotence of the verbs matters when you choose between them: repeating a DELETE of a book that is already gone is conventionally a 404 (or a 204 if you decide delete is idempotent in your API; pick one and document it), whereas repeating a POST creates another book — which is why a unique constraint and a 409 response (next section) belong together with 201.

from fastapi import APIRouter, FastAPI, HTTPException, Response, status
from fastapi.testclient import TestClient
from pydantic import BaseModel

router = APIRouter(prefix="/books", tags=["books"])
BOOKS: dict[int, dict] = {}


class BookIn(BaseModel):
    title: str
    isbn: str


class BookOut(BookIn):
    id: int


@router.post("", response_model=BookOut, status_code=status.HTTP_201_CREATED)
def create_book(book: BookIn, response: Response):
    new_id = len(BOOKS) + 1
    BOOKS[new_id] = {"id": new_id, **book.model_dump()}
    response.headers["Location"] = f"/books/{new_id}"
    return BOOKS[new_id]


@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int) -> None:
    if BOOKS.pop(book_id, None) is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")


app = FastAPI()
app.include_router(router)

c = TestClient(app)
r = c.post("/books", json={"title": "Dune", "isbn": "978-0441013593"})
print(r.status_code, r.headers["location"], r.json())
r = c.delete("/books/1")
print(r.status_code, repr(r.content))
r = c.delete("/books/1")
print(r.status_code, r.json())
201 /books/1 {'title': 'Dune', 'isbn': '978-0441013593', 'id': 1}
204 b''
404 {'detail': 'Book not found'}

The Response parameter is used here only to add a header to the response FastAPI builds from the return value; headers and cookies are the subject of Custom Responses. The return value is still validated and filtered by response_model, described in Response Models & Return Types.

With curl, -i prints the status line and headers:

curl -i -X POST localhost:8000/books -H 'Content-Type: application/json' \
     -d '{"title": "Dune", "isbn": "978-0441013593"}'
HTTP/1.1 201 Created
location: /books/1
content-type: application/json

{"id":1,"title":"Dune","isbn":"978-0441013593"}

Pitfalls of status_code

  • The default is 200, even for POST. FastAPI does not guess 201 from the verb. Forgetting status_code=201 on a create endpoint is the most common mistake.

  • The decorator parameter, not the function parameter. def f(status_code: int = 201) declares a query parameter called status_code.

  • A 204 with a body. The body is dropped silently; if a client reports that "my response is empty", check whether a 204 (or 304, or a 1xx) is declared.

  • The status code only applies to success. An exception raised by your function produces its own status code and ignores the one in the decorator.

  • Only one success code is documented. If a route can answer 200 or 201, the second code is invisible in OpenAPI unless you declare it with responses= (see below).

HTTPException: Detail and Headers

HTTPException is an ordinary Python exception with extra data for HTTP. You raise it, you do not return it. As with any exception, the rest of the function stops at that line, which has a very useful consequence: you can raise it from a helper function or a dependency, and the whole request ends with that error without the endpoint having to check anything.

from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.testclient import TestClient

app = FastAPI()

BOOKS = {1: {"id": 1, "title": "Dune"}}


def get_book_or_404(book_id: int) -> dict:
    book = BOOKS.get(book_id)
    if book is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Book {book_id} not found",
        )
    return book


@app.get("/books/{book_id}")
def read_book(book: Annotated[dict, Depends(get_book_or_404)]) -> dict:
    return book


@app.get("/me")
def me(authorization: Annotated[str | None, Header()] = None) -> dict:
    if authorization != "Bearer demo":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return {"user": "demo"}


@app.post("/books")
def create_book(title: str) -> dict:
    if any(b["title"] == title for b in BOOKS.values()):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail={"code": "duplicate_title", "title": title},
        )
    return {"title": title}

c = TestClient(app)
r = c.get("/books/1"); print(r.status_code, r.json())
r = c.get("/books/2"); print(r.status_code, r.json())
r = c.get("/me"); print(r.status_code, r.json(), r.headers["www-authenticate"])
r = c.post("/books?title=Dune"); print(r.status_code, r.json())
200 {'id': 1, 'title': 'Dune'}
404 {'detail': 'Book 2 not found'}
401 {'detail': 'Not authenticated'} Bearer
409 {'detail': {'code': 'duplicate_title', 'title': 'Dune'}}

Started with fastapi dev and called with curl -i, the same routes answer as follows (headers other than the interesting ones trimmed):

curl -i localhost:8000/me
curl -i localhost:8000/books/2
curl -i -X DELETE localhost:8000/books/1
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/json

{"detail":"Not authenticated"}

HTTP/1.1 404 Not Found
content-type: application/json

{"detail":"Book 2 not found"}

HTTP/1.1 405 Method Not Allowed
allow: GET
content-type: application/json

{"detail":"Method Not Allowed"}

The constructor is HTTPException(status_code, detail=None, headers=None):

Parameter Type Meaning

status_code

int

The HTTP status code to send. Required.

detail

any JSON-compatible value

Placed under the detail key of the JSON body. A string is typical; a dict or a list is also fine and is serialised for you. If omitted, the response body is the standard reason phrase for the code.

headers

Mapping[str, str] | None

Extra response headers. Needed by some protocols: WWW-Authenticate on 401, Retry-After on 429/503, Allow on 405. Since 0.128.7 the annotation accepts any Mapping, not only dict.

Points worth knowing:

  • The body shape is fixed. The default handler always answers \{"detail": …​}. Anything else (an error code, an instance, a list of problems) has to be inside detail, or you override the handler (below).

  • Use it for client errors. The API reference says it plainly: HTTPException is for client errors, invalid authentication and invalid data, not for server errors in your code. Do not raise HTTPException(500) to report a bug; let the exception propagate so that it is logged (see "Unhandled errors and `500`").

  • 401 needs WWW-Authenticate. HTTP requires the header on a 401; the security classes of FastAPI send it for you (see Security Fundamentals). Since 0.122.0 they answer 401 rather than 403 when credentials are missing.

  • Dependencies and yield. A dependency can raise HTTPException before it yields, and since 0.106.0 also after the yield; Dependency Injection explains when that response can still change.

  • The OpenAPI document does not list it. Exceptions are runtime events; FastAPI cannot know that read_book may answer 404. Declare it with responses= (see "Documenting Error Responses").

  • Two classes, one name. fastapi.HTTPException extends starlette.exceptions.HTTPException; see “HTTPException` versus Starlette’s `HTTPException” below.

For WebSocket endpoints the equivalent is WebSocketException, importable from fastapi; see WebSockets.

Domain Exceptions Mapped to HTTP Errors

Raising HTTPException from every layer works, but it ties your business logic to HTTP: a service that raises HTTPException(404) cannot be reused from a command-line tool, a background job or a unit test that knows nothing about status codes. A cleaner structure follows the layers described in Introduction & Architecture:

  • the service/data layer raises exceptions that describe what went wrong in the domain (Missing, Duplicate);

  • the web layer decides what each of them means in HTTP (404, 409) in one place, using exception handlers.

The web functions stay free of try/except for those cases, and changing the mapping (say, Duplicate becomes 422) touches one handler.

from typing import Annotated

from fastapi import APIRouter, Depends, FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel


# --- domain layer: knows nothing about HTTP -------------------------------
class BookshelfError(Exception):
    """Base class for domain errors."""


class Missing(BookshelfError):
    def __init__(self, kind: str, key: str):
        super().__init__(f"{kind} {key!r} not found")
        self.kind, self.key = kind, key


class Duplicate(BookshelfError):
    def __init__(self, kind: str, key: str):
        super().__init__(f"{kind} {key!r} already exists")
        self.kind, self.key = kind, key


class BookService:
    def __init__(self) -> None:
        self._books: dict[str, str] = {"978-0441013593": "Dune"}

    def get(self, isbn: str) -> str:
        try:
            return self._books[isbn]
        except KeyError:
            raise Missing("book", isbn) from None

    def create(self, isbn: str, title: str) -> str:
        if isbn in self._books:
            raise Duplicate("book", isbn)
        self._books[isbn] = title
        return title


_service = BookService()


def get_service() -> BookService:
    return _service


Service = Annotated[BookService, Depends(get_service)]


# --- web layer: maps domain errors to HTTP --------------------------------
class BookIn(BaseModel):
    isbn: str
    title: str


router = APIRouter(prefix="/books", tags=["books"])


@router.get("/{isbn}")
def read_book(isbn: str, service: Service) -> dict:
    return {"isbn": isbn, "title": service.get(isbn)}


@router.post("", status_code=status.HTTP_201_CREATED)
def create_book(book: BookIn, service: Service) -> dict:
    return {"isbn": book.isbn, "title": service.create(book.isbn, book.title)}


app = FastAPI()
app.include_router(router)


@app.exception_handler(Missing)
async def missing_handler(request: Request, exc: Missing) -> JSONResponse:
    return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": str(exc)})


@app.exception_handler(Duplicate)
async def duplicate_handler(request: Request, exc: Duplicate) -> JSONResponse:
    return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"detail": str(exc)})


c = TestClient(app)
print(c.get("/books/978-0441013593").json())
r = c.get("/books/000"); print(r.status_code, r.json())
r = c.post("/books", json={"isbn": "1", "title": "A"}); print(r.status_code, r.json())
r = c.post("/books", json={"isbn": "1", "title": "A"}); print(r.status_code, r.json())
{'isbn': '978-0441013593', 'title': 'Dune'}
404 {'detail': "book '000' not found"}
201 {'isbn': '1', 'title': 'A'}
409 {'detail': "book '1' already exists"}

Two details of the service code are deliberate: raise Missing(…​) from None hides the internal KeyError from the traceback, and the exception stores the structured fields (kind, key) so that a handler can use them for anything beyond the message. Python’s exception mechanics (hierarchies, chaining, from None) are covered in Exceptions; the same layering idea with a different framework is shown in Spring Boot REST APIs and, for ASP.NET Core, in Error Handling, Logging & Observability.

When many domain errors exist, avoid one handler per class. The next section shows a single handler for a whole exception hierarchy.

Custom Exception Handlers

A handler is a function handler(request, exc) that returns a Response. It may be async def or plain def. It is registered on the application in either of two equivalent ways:

  • the decorator @app.exception_handler(ExceptionClass);

  • the method app.add_exception_handler(ExceptionClass, handler), handy when handlers live in another module, or the exception_handlers=\{…​} dictionary argument of FastAPI(…​).

The key can be an exception class or an integer status code. A handler registered for a class is selected by walking the class hierarchy of the exception that was raised, so one handler for a base class serves all its subclasses; if both a base class and a subclass are registered, the most specific one wins.

from fastapi import APIRouter, FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient


class BookshelfError(Exception):
    status_code = 400
    code = "bookshelf_error"


class Missing(BookshelfError):
    status_code = 404
    code = "not_found"


class Duplicate(BookshelfError):
    status_code = 409
    code = "duplicate"


app = FastAPI()


# one handler for the whole hierarchy: Starlette looks the handler up
# through the exception's class hierarchy (its MRO)
async def bookshelf_handler(request: Request, exc: BookshelfError) -> JSONResponse:
    return JSONResponse(
        status_code=exc.status_code,
        content={"code": exc.code, "detail": str(exc), "path": request.url.path},
    )


app.add_exception_handler(BookshelfError, bookshelf_handler)


@app.get("/missing")
def missing():
    raise Missing("no such book")


@app.get("/dup")
def dup():
    raise Duplicate("book exists")


@app.get("/other")
def other():
    raise BookshelfError("something else")


@app.get("/boom")
def boom():
    raise RuntimeError("bug")

c = TestClient(app, raise_server_exceptions=False)
for p in ("/missing", "/dup", "/other"):
    r = c.get(p); print(r.status_code, r.json())
404 {'code': 'not_found', 'detail': 'no such book', 'path': '/missing'}
409 {'code': 'duplicate', 'detail': 'book exists', 'path': '/dup'}
400 {'code': 'bookshelf_error', 'detail': 'something else', 'path': '/other'}

The hierarchy lets each exception class carry its own status code and machine-readable code; the handler is written once.

Notes on handlers:

  • Registration is per application. APIRouter has neither exception_handler nor add_exception_handler; register handlers on the FastAPI instance (a mounted sub-application has its own handlers, see Sub-Applications, WSGI & GraphQL).

  • Handlers can use the request. The request argument exposes the URL, headers and request.state, which is how the example fills the path field. Request-scoped identifiers (a correlation id set by middleware, see Middleware, CORS & Proxies) are a good fit for the error body.

  • Handlers return, they do not raise. Build and return a Response; use JSONResponse (or a subclass) so the body is valid JSON.

Unhandled Errors and 500

Any exception for which no handler exists is an error in Starlette’s vocabulary, as opposed to a handled exception. Handled exceptions (HTTPException and anything you registered) go through the application’s middleware like a normal response. Errors bubble up to ServerErrorMiddleware, the outermost layer, which sends the generic 500 Internal Server Error response and then re-raises the exception so that the server logs the traceback. Two consequences follow from that position: user middleware such as CORS has not yet seen the 500 response, so it may lack headers a browser expects (see Middleware, CORS & Proxies), and the exception is always re-raised after your handler has run.

You can replace the generic body by registering a handler for Exception (or the integer key 500), which is where an error identifier, structured logging and a safe message belong. Do not return exception text to the client: it can contain connection strings, file paths and query fragments.

import logging
import uuid

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient

logging.basicConfig(level=logging.ERROR, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("bookshelf")

app = FastAPI()


@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception) -> JSONResponse:
    error_id = uuid.uuid4().hex[:8]
    log.error("unhandled error %s on %s", error_id, request.url.path, exc_info=exc)
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error", "error_id": error_id},
    )


@app.exception_handler(404)
async def not_found(request: Request, exc: Exception) -> JSONResponse:
    return JSONResponse(status_code=404, content={"detail": f"No route for {request.url.path}"})


@app.get("/boom")
def boom():
    raise RuntimeError("connection to db.internal failed")


# default TestClient re-raises server exceptions into the test
try:
    TestClient(app).get("/boom")
except RuntimeError as e:
    print("re-raised:", e)

c = TestClient(app, raise_server_exceptions=False)
r = c.get("/boom"); print(r.status_code, sorted(r.json()))
r = c.get("/nothing"); print(r.status_code, r.json())
re-raised: connection to db.internal failed
500 ['detail', 'error_id']
404 {'detail': 'No route for /nothing'}

The default TestClient re-raises server exceptions into the test (the first print); construct it with raise_server_exceptions=False to see the response a real client would get. The traceback of the error is logged under the key error_id, so a support request that quotes the identifier leads straight to the log entry, and the client never sees connection to db.internal failed.

Notes:

  • A handler registered under an integer key such as 404 serves every HTTPException carrying that status: both the one Starlette raises when no route matches and the ones your own code raises (in the run above only the first is shown). It is therefore the place to customise the "unknown URL" response, but it also changes the body of your own HTTPException(404, …​).

  • In debug mode Starlette responds with a traceback page instead of the 500 handler. Never enable it in production.

  • An exception in a BackgroundTask reaches the Exception handler too, but the response was already sent, so the handler’s response is discarded; use the handler for logging in that case.

ResponseValidationError: a server error that looks like a client error

If a function returns data that does not match its response_model or return annotation, FastAPI raises ResponseValidationError. This is your bug, not the client’s, so it produces a 500 — never a 422. The exception extends ValidationException, which is a plain Exception subclass; RequestValidationError and ResponseValidationError are siblings.

import logging

from fastapi import FastAPI, Request
from fastapi.exceptions import ResponseValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

log = logging.getLogger("bookshelf")
app = FastAPI()


class BookOut(BaseModel):
    title: str
    pages: int


@app.exception_handler(ResponseValidationError)
async def bad_response(request: Request, exc: ResponseValidationError) -> JSONResponse:
    # our bug, not the client's: log the details, tell the client nothing about them
    log.error("response for %s violates its model: %s", request.url.path, exc.errors())
    return JSONResponse(status_code=500, content={"detail": "Internal server error"})


@app.get("/bad", response_model=BookOut)
def bad() -> dict:
    return {"title": "x"}


r = TestClient(app).get("/bad")
print(r.status_code, r.json())
response for /bad violates its model: [{'type': 'missing', 'loc': ('response', 'pages'), 'msg': 'Field required', 'input': {'title': 'x'}}]
500 {'detail': 'Internal server error'}

The loc begins with response instead of body, query or path, which is how you tell it apart in a log. Since 0.101.1 the exception’s text includes these details, so the server log already shows them even without a handler.

Overriding the Validation and HTTPException Handlers

FastAPI installs three default handlers: one for Starlette’s HTTPException (\{"detail": …​}), one for RequestValidationError (422 with the list of problems), and one for WebSocketRequestValidationError. Because they are ordinary registered handlers, you replace them the same way as your own.

The default 422 body

For a request that fails validation the default body is a detail list with one entry per problem:

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "book_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

Each entry has the Pydantic v2 fields type (a stable code such as missing, int_parsing, string_too_short, less_than_equal, json_invalid, value_error), loc (where the problem is: path, query, header, cookie, body, then the field path), msg (human text), input (the offending value) and, for some errors, ctx (the constraint, for example the limit that was exceeded). The Pydantic v1 payloads shown in older guides (value_error.missing, type_error.integer) no longer exist.

A custom RequestValidationError handler

The handler receives the RequestValidationError; exc.errors() is the list above and exc.body is the request body that was received (parsed JSON when it parsed, otherwise the raw text). In the example the 422 is reshaped into a flat list the front end can bind to input fields, and HTTPException responses are logged before being delegated to the default handler.

from fastapi import FastAPI, HTTPException, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exception_handlers import (
    http_exception_handler,
    request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()


class ReviewIn(BaseModel):
    rating: int = Field(ge=1, le=5)
    text: str = Field(min_length=3)

    @field_validator("text")
    @classmethod
    def no_shouting(cls, v: str) -> str:
        if v.isupper():
            raise ValueError("please do not shout")
        return v


@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
    # exc.errors() can hold non-JSON values (e.g. the exception in "ctx"), so encode it
    errors = [
        {"field": ".".join(str(p) for p in e["loc"][1:]), "where": e["loc"][0], "message": e["msg"], "type": e["type"]}
        for e in exc.errors()
    ]
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
        content={"title": "Validation failed", "errors": jsonable_encoder(errors)},
    )


@app.exception_handler(StarletteHTTPException)
async def http_handler(request: Request, exc: StarletteHTTPException):
    print("HTTP error:", exc.status_code, exc.detail)
    return await http_exception_handler(request, exc)   # reuse the default response


@app.post("/books/{book_id}/reviews")
def add_review(book_id: int, review: ReviewIn) -> dict:
    if book_id == 404:
        raise HTTPException(404, "no such book")
    return {"book_id": book_id, **review.model_dump()}

c = TestClient(app)
r = c.post("/books/x/reviews", json={"rating": 9, "text": "AB"})
print(r.status_code, r.json())
print(c.post("/books/1/reviews", json={"rating": 5, "text": "LOUD"}).json())
print(c.post("/books/404/reviews", json={"rating": 5, "text": "fine"}).json())
print(c.get("/nowhere").json())

The first request is invalid in three places (path, rating, text); the response is:

{
  "title": "Validation failed",
  "errors": [
    {"field": "book_id", "where": "path", "message": "Input should be a valid integer, unable to parse string as an integer", "type": "int_parsing"},
    {"field": "rating", "where": "body", "message": "Input should be less than or equal to 5", "type": "less_than_equal"},
    {"field": "text", "where": "body", "message": "String should have at least 3 characters", "type": "string_too_short"}
  ]
}

The other prints show that a custom @field_validator failure arrives as value_error (message prefixed with Value error, `), and that the delegated `HTTPException responses keep the default body:

HTTP error: 404 no such book
HTTP error: 404 Not Found
HTTP error: 405 Method Not Allowed

(Those lines are printed by the handler to standard output; the 404 and 405 come from Starlette’s router, not from your code, which is why the handler must be registered for the Starlette class.)

Guidelines for validation handlers:

  • Use jsonable_encoder when you pass exc.errors() through. A ctx can contain the original exception object (for a failing ValueError in a validator the object is in ctx["error"]), which JSONResponse cannot serialise. The default handler applies jsonable_encoder for this reason. Building the strings yourself, as the example does, avoids the problem.

  • Do not return str(exc). The string form of a RequestValidationError includes the file name and line of your endpoint (it was checked in a run); the official documentation warns that returning it leaks information about your system, and extracts each error individually instead.

  • Echoing exc.body helps in development and is also a way to leak data (passwords in a login body) into responses and logs. Keep it out of production handlers or filter the fields.

  • The status code of validation errors is yours to choose. Some teams answer 400 for everything the client got wrong. That is legitimate, but then OpenAPI still documents 422; change the documented code with the application’s responses or accept the mismatch knowingly.

  • Replacing is global. A handler for RequestValidationError applies to every route, and also to the validation of dependency parameters.

HTTPException versus Starlette’s HTTPException

fastapi.HTTPException extends starlette.exceptions.HTTPException; the only difference is that FastAPI’s accepts any JSON-compatible detail while Starlette’s accepts only strings. You keep raising FastAPI’s in your code, but register handlers for Starlette’s, so that errors raised by Starlette itself (unmatched route 404, wrong method 405) and by third-party extensions are handled too.

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient

app = FastAPI()


@app.exception_handler(HTTPException)       # FastAPI's class: too narrow
async def handler(request, exc):
    return JSONResponse({"custom": exc.detail}, status_code=exc.status_code)


@app.get("/a")
def a():
    raise HTTPException(400, "mine")


c = TestClient(app)
print(c.get("/a").json(), c.get("/zzz").json())
{'custom': 'mine'} {'detail': 'Not Found'}

Your own HTTPException went through the custom handler, but the router’s 404 — a Starlette exception — bypassed it and kept the default body. Registering for StarletteHTTPException (as in the previous example) fixes it. The practical rule: raise fastapi.HTTPException, handle starlette.exceptions.HTTPException.

Reusing the default handlers

The default handlers are importable from fastapi.exception_handlers: http_exception_handler and request_validation_exception_handler. Delegating to them keeps the standard responses while you add logging, metrics or tracing. The http_handler in the previous example does exactly that: return await http_exception_handler(request, exc).

from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import (
    http_exception_handler,
    request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()
seen: list[str] = []


@app.exception_handler(StarletteHTTPException)
async def log_http_error(request, exc):
    seen.append(f"http {exc.status_code} {request.url.path}")
    return await http_exception_handler(request, exc)


@app.exception_handler(RequestValidationError)
async def log_validation_error(request, exc):
    seen.append(f"validation {len(exc.errors())} problem(s) {request.url.path}")
    return await request_validation_exception_handler(request, exc)


@app.get("/books/{book_id}")
def read_book(book_id: int):
    if book_id == 3:
        raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
    return {"book_id": book_id}


c = TestClient(app)
print(c.get("/books/3").json())
print(c.get("/books/x").status_code)
print(seen)
{'detail': "Nope! I don't like 3."}
422
['http 418 /books/3', 'validation 1 problem(s) /books/x']

Additional Status Codes with JSONResponse

By default FastAPI wraps whatever you return in a JSONResponse using the decorator’s status code. If one endpoint must answer with more than one success code, return a Response yourself: the classic case is an upsert, PUT that answers 200 when it replaced an existing resource and 201 when it created it.

from typing import Annotated

from fastapi import Body, FastAPI, Response, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
SHELF: dict[str, dict] = {"978-0441013593": {"title": "Dune", "pages": 412}}


class Book(BaseModel):
    title: str
    pages: int


@app.put("/books/{isbn}")
def upsert(isbn: str, book: Annotated[Book, Body()]):
    if isbn in SHELF:
        SHELF[isbn] = book.model_dump()
        return SHELF[isbn]
    SHELF[isbn] = book.model_dump()
    return JSONResponse(status_code=status.HTTP_201_CREATED, content=jsonable_encoder(book))


c = TestClient(app)
body = {"title": "Emma", "pages": 474}
r = c.put("/books/1", json=body); print(r.status_code, r.json())
r = c.put("/books/1", json=body); print(r.status_code, r.json())
print(list(c.get("/openapi.json").json()["paths"]["/books/{isbn}"]["put"]["responses"]))
201 {'title': 'Emma', 'pages': 474}
200 {'title': 'Emma', 'pages': 474}
['200', '422']

When you return a Response directly it is sent as is: it is not validated, not filtered through a response_model, and not converted, so the content must already be valid JSON. Pydantic models and datetime values need jsonable_encoder first, as in the example. The last line shows the OpenAPI effect: the 201 does not appear, because FastAPI cannot know in advance what you will return. Declare it with responses= (next sections).

Dynamic Status with a Response Parameter

Returning a JSONResponse loses the response_model. When you want to keep filtering and validation but still vary the status per request, declare a parameter of type Response and set status_code on it. FastAPI uses this temporary response to collect the status code (plus any headers and cookies) and copies them into the final response, whose body is the value you returned after response_model processing.

from fastapi import FastAPI, Response, status
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
SHELF: dict[str, dict] = {"978-0441013593": {"title": "Dune", "pages": 412}}


class Book(BaseModel):
    title: str
    pages: int


class BookOut(BaseModel):
    title: str


@app.put("/v2/books/{isbn}", response_model=BookOut)
def upsert_v2(isbn: str, book: Book, response: Response):
    if isbn not in SHELF:
        response.status_code = status.HTTP_201_CREATED
        response.headers["Location"] = f"/v2/books/{isbn}"
    SHELF[isbn] = book.model_dump()
    return SHELF[isbn]


c = TestClient(app)
body = {"title": "Emma", "pages": 474}
r = c.put("/v2/books/2", json=body); print(r.status_code, r.json(), r.headers["location"])
r = c.put("/v2/books/2", json=body); print(r.status_code, r.json(), "location" in r.headers)
201 {'title': 'Emma'} /v2/books/2
200 {'title': 'Emma'} False

The response body was filtered to the one field of BookOut (pages is gone), the first call answered 201 with a Location header, and the second 200 without. Remember:

  • The decorator’s status_code (here the default 200) is the value used unless you assign another one.

  • The Response parameter can also be declared in a dependency. When both a dependency and the endpoint assign a status, the one assigned last wins; dependencies run first, so the endpoint’s assignment prevails.

  • The OpenAPI document still shows only the decorator’s code.

  • Do not use this to report errors; raise an exception instead, so the error path stays the same everywhere.

Documenting Error Responses in OpenAPI

Because exceptions are invisible to the generator, an API client reading openapi.json sees only 200 and 422. The responses parameter of a path operation (or of FastAPI() and APIRouter() for every route they own) adds the other outcomes: a dictionary whose keys are status codes (or "default") and whose values describe each response. The special key model takes a Pydantic model and is turned into a JSON Schema; description sets the text; content and headers add media types and headers. The key model is not part of OpenAPI; FastAPI replaces it with a reference into components/schemas.

import json
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Response, status
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class ErrorBody(BaseModel):
    detail: str


class Book(BaseModel):
    isbn: str
    title: str


BOOKS = {"1": Book(isbn="1", title="Dune")}

NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorBody, "description": "Book not found"}}


@app.get(
    "/books/{isbn}",
    response_model=Book,
    responses={**NOT_FOUND, 409: {"model": ErrorBody}, 500: {"description": "Unexpected error"}},
)
def read_book(isbn: str):
    if isbn not in BOOKS:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
    return BOOKS[isbn]


c = TestClient(app)
spec = c.get("/openapi.json").json()
print(json.dumps(spec["paths"]["/books/{isbn}"]["get"]["responses"], indent=1))

The resulting responses object (the order of keys follows FastAPI’s output):

{
 "200": {
  "description": "Successful Response",
  "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Book"}}}
 },
 "404": {
  "description": "Book not found",
  "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorBody"}}}
 },
 "409": {
  "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorBody"}}},
  "description": "Conflict"
 },
 "500": {"description": "Unexpected error"},
 "422": {
  "description": "Validation Error",
  "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}
 }
}

Observations:

  • Swagger UI and ReDoc now list 404, 409 and 500 under the operation and show the ErrorBody schema; client generators produce typed error classes from them.

  • A response without a description gets the standard phrase (Conflict).

  • The declaration is documentation only. It does not make the function return that status; your code must still raise the exception or return the JSONResponse.

  • Reuse is plain Python: keep dictionaries such as NOT_FOUND in one module and merge them with **, as above. A responses value on APIRouter(…​) or FastAPI(…​) applies to all its routes; the key "default" describes "any other status".

  • For the 422 of validation errors FastAPI documents the schema HTTPValidationError for you. If you replace the handler with a different body shape, document your shape with responses=\{422: \{"model": YourModel}} so the contract and the implementation agree. The OpenAPI side of this is covered in OpenAPI & Interactive Docs.

RFC 9457 Problem Details (an Optional Convention)

\{"detail": "…​"} is FastAPI’s convention, not a standard. If you want a standard error format that any HTTP client or gateway can recognise, use problem details, defined in RFC 9457 (which obsoletes RFC 7807). A problem is a JSON object served with the media type application/problem+json and the following members:

Member Meaning

type

A URI that identifies the kind of problem; when omitted, about:blank, meaning "nothing beyond the status code".

title

A short, human-readable summary of the problem type; it should not vary from occurrence to occurrence.

status

The HTTP status code (a convenience for consumers that lose the status line).

detail

An explanation specific to this occurrence.

instance

A URI reference that identifies this occurrence (the request path, or a URL of an incident record).

Extension members are allowed (the errors list and isbn below); clients ignore the ones they do not know. FastAPI has no built-in support; implementing it is a few handlers: one per class that can reach the client (your domain errors, Starlette’s HTTPException, RequestValidationError) so that every error is a problem document, whether it came from your code or from the router.

from http import HTTPStatus
from typing import Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
from starlette.exceptions import HTTPException as StarletteHTTPException

PROBLEM_JSON = "application/problem+json"


class Problem(BaseModel):
    """RFC 9457 problem details object (members used by Bookshelf)."""

    model_config = ConfigDict(extra="allow")

    type: str = Field(default="about:blank")
    title: str
    status: int
    detail: str | None = None
    instance: str | None = None


class ProblemResponse(JSONResponse):
    media_type = PROBLEM_JSON


class BookshelfProblem(Exception):
    """A domain error that carries its own problem type."""

    def __init__(self, status: int, type_: str, title: str, detail: str, **extensions: Any):
        self.status, self.type_, self.title, self.detail = status, type_, title, detail
        self.extensions = extensions


def problem(request: Request, status: int, *, type_: str = "about:blank",
            title: str | None = None, detail: str | None = None, **ext: Any) -> ProblemResponse:
    body = Problem(
        type=type_,
        title=title or HTTPStatus(status).phrase,
        status=status,
        detail=detail,
        instance=request.url.path,
        **ext,
    )
    return ProblemResponse(status_code=status, content=jsonable_encoder(body, exclude_none=True))


app = FastAPI(responses={"default": {"model": Problem, "content": {PROBLEM_JSON: {}}}})


@app.exception_handler(BookshelfProblem)
async def bookshelf_problem(request: Request, exc: BookshelfProblem):
    return problem(request, exc.status, type_=exc.type_, title=exc.title, detail=exc.detail, **exc.extensions)


@app.exception_handler(StarletteHTTPException)
async def http_problem(request: Request, exc: StarletteHTTPException):
    r = problem(request, exc.status_code, detail=str(exc.detail))
    r.headers.update(exc.headers or {})          # keep e.g. WWW-Authenticate
    return r


@app.exception_handler(RequestValidationError)
async def validation_problem(request: Request, exc: RequestValidationError):
    errors = [{"pointer": "/" + "/".join(map(str, e["loc"])), "detail": e["msg"]} for e in exc.errors()]
    return problem(request, 422, type_="https://api.example.com/problems/validation",
                   title="Request validation failed", detail="One or more fields are invalid.",
                   errors=errors)


@app.get("/books/{isbn}")
def read_book(isbn: str, limit: int = 10) -> dict:
    if isbn == "0":
        raise BookshelfProblem(409, "https://api.example.com/problems/out-of-stock",
                               "Out of stock", "No copies of this book are left.", isbn=isbn)
    if isbn == "1":
        raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"})
    return {"isbn": isbn}


import json

c = TestClient(app)
for path in ("/books/0", "/books/1", "/nope", "/books/2?limit=x"):
    r = c.get(path)
    print(r.status_code, r.headers["content-type"], r.headers.get("www-authenticate"))
    print(json.dumps(r.json(), indent=1))

The four requests answer (output of the run, content-type shown first):

409 application/problem+json None
{"type": "https://api.example.com/problems/out-of-stock", "title": "Out of stock", "status": 409,
 "detail": "No copies of this book are left.", "instance": "/books/0", "isbn": "0"}
401 application/problem+json Bearer
{"type": "about:blank", "title": "Unauthorized", "status": 401,
 "detail": "Not authenticated", "instance": "/books/1"}
404 application/problem+json None
{"type": "about:blank", "title": "Not Found", "status": 404,
 "detail": "Not Found", "instance": "/nope"}
422 application/problem+json None
{"type": "https://api.example.com/problems/validation", "title": "Request validation failed", "status": 422,
 "detail": "One or more fields are invalid.", "instance": "/books/2",
 "errors": [{"pointer": "/query/limit", "detail": "Input should be a valid integer, unable to parse string as an integer"}]}

(The JSON is condensed here; the program prints it indented.)

Design notes:

  • ProblemResponse is a JSONResponse subclass that only changes media_type; the same trick works for any custom media type, see Custom Responses.

  • The handler for StarletteHTTPException copies exc.headers to the response, so WWW-Authenticate and Allow survive the conversion (note Bearer in the second output).

  • FastAPI(responses=\{"default": …​}) documents the problem schema for every route and the media type in OpenAPI; individual routes still list their specific codes.

  • The type URIs are identifiers; they do not have to be resolvable, although a human-readable page there is the point of the format. The example.com domain here is the reserved placeholder; use your own.

  • Do not put exception messages or stack traces in detail; it is shown to clients.

  • If your API already shipped \{"detail": …​}, switching changes the contract of every error. Treat it as a versioned change, or keep both and negotiate with the Accept header.

  • Spring and ASP.NET Core have built-in support for the same format (ProblemDetail and ProblemDetails); the concept is identical.

Troubleshooting by Status Code

When a client reports "the API returns N", the status code narrows the search to a layer. Use the table to decide where to look first; the FastAPI-specific rows that can be reproduced in a few lines are reproduced after the table.

Code Typical causes in a FastAPI service What to check

307/308

A route declared with a trailing slash (/shelf/) was called without it (or the reverse): FastAPI answers a 307 redirect to the canonical path. Behind a proxy the redirect may point to http:// and an internal host.

Call the exact path; make clients follow redirects with the method preserved; fix proxy headers (see Middleware, CORS & Proxies).

400

Not produced by FastAPI validation (that is 422). Comes from your own HTTPException(400), a handler that remaps validation, a malformed multipart body, or a proxy rejecting a bad request line or oversized header.

Read the detail; reproduce with curl -i directly against the app, bypassing the proxy.

401

Missing or invalid credentials. Since 0.122.0 the built-in security classes answer 401 (not 403) when the header is absent; expired JWT, wrong token type, wrong scheme.

Is the Authorization header present and spelled Bearer <token>? Is the clock right (exp)? Does the response carry WWW-Authenticate?

403

Authenticated but not authorised: missing scope or role, or a CORS or CSRF rule in front. Older FastAPI versions also used 403 for a missing credential; see the how-to on the old 403 behaviour.

Check the scopes in the token against the route’s requirements; check the proxy or WAF.

404

No route matches (typo, missing prefix of the APIRouter, root_path or mount prefix wrong behind a proxy, router never included); or your code raised it because the record does not exist. An unmatched route answers \{"detail": "Not Found"}, so look at the OpenAPI document to tell the two cases apart.

Open /docs: does the path appear exactly as called? Is include_router executed? Is the proxy stripping a prefix?

405

The path exists but not for that method; the Allow header lists the allowed ones. Often a GET against a POST-only route, or a form submitted with the wrong method.

Compare the method with the OpenAPI document; read Allow.

406

Not produced by FastAPI itself; a proxy or your own content negotiation.

Review the Accept header and the proxy configuration.

409

Your own domain rule (duplicate key, state conflict, optimistic-lock failure). Raised by your handlers, not by the framework.

Look for the unique constraint that was hit; return which field conflicts.

413

Not produced by FastAPI or Starlette for ordinary bodies. Comes from the proxy (client_max_body_size in Nginx) or from your own limit when reading uploads.

Raise the proxy limit, or enforce your own explicit limit while streaming (see Forms & File Uploads).

415

Raised by your own code or a proxy. A JSON body sent with the wrong Content-Type does not give 415 in FastAPI: it gives 422 (see below).

See the next rows.

422

The most common FastAPI error. Missing field (type: missing), wrong type (int_parsing), constraint violated, invalid JSON (json_invalid), a body sent without Content-Type: application/json (model_attributes_type, since the strict check of 0.132.0), a form sent to a JSON endpoint, a query parameter that should have been a body.

Read loc and type in the response first; they point at the exact place. Then compare the request with the OpenAPI schema. See Request Object & Content-Type.

429

Rate limiting in a gateway or in your own dependency/middleware; FastAPI has none built in.

Look for Retry-After; check who enforces the limit.

500

An unhandled exception, or a response that violates its response_model (ResponseValidationError, loc begins with response). Also dependency or lifespan failures such as a database being unreachable.

Find the traceback in the server log (quote the error_id from your handler); reproduce with the same input in a test.

502/503/504

Not produced by FastAPI; produced by the proxy or load balancer in front: the worker crashed, is restarting, is out of workers, or a request exceeded the proxy timeout. 503 is also what your own code should answer when a dependency is down.

Check that the process is alive and listening on the expected host and port, worker count and request timeouts, health checks (see Deployment).

Reproduction of the 404, 405, 307, 422 and 500 rows:

from typing import Annotated

from fastapi import Depends, FastAPI, Form
from fastapi.security import HTTPBearer
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
bearer = HTTPBearer()


class Book(BaseModel):
    title: str


class BookOut(BaseModel):
    title: str
    pages: int


@app.post("/books")
def create(b: Book) -> Book:
    return b


@app.get("/shelf/")
def shelf() -> list: return []


@app.get("/secure")
def secure(cred: Annotated[object, Depends(bearer)]): return "ok"


@app.get("/bad", response_model=BookOut)
def bad(): return {"title": "x"}


@app.post("/login")
def login(username: Annotated[str, Form()]): return username


c = TestClient(app, raise_server_exceptions=False)
def show(label, r):
    print(label, r.status_code, str(r.json() if r.headers.get("content-type","").startswith("application/json") else r.text)[:150], r.headers.get("location"))

show("404 route", c.get("/nope"))
show("405", c.get("/books"))
show("307 slash", c.get("/shelf", follow_redirects=False))
show("422 no body", c.post("/books"))
show("422 bad json", c.post("/books", content="{bad", headers={"content-type": "application/json"}))
show("422 no ctype", c.post("/books", content='{"title":"x"}'))
show("422 wrong ctype", c.post("/books", content='{"title":"x"}', headers={"content-type": "text/plain"}))
show("401 bearer", c.get("/secure"))
show("500 resp validation", c.get("/bad"))
show("422 form", c.post("/login", data={}))
404 route 404 {'detail': 'Not Found'} None
405 405 {'detail': 'Method Not Allowed'} None
307 slash 307  http://testserver/shelf/
422 no body 422 {'detail': [{'type': 'missing', 'loc': ['body'], 'msg': 'Field required', 'input': None}]} None
422 bad json 422 {'detail': [{'type': 'json_invalid', 'loc': ['body', 1], 'msg': 'JSON decode error', 'input': {}, 'ctx': {'error': 'Expecting property name enclosed i None
422 no ctype 422 {'detail': [{'type': 'model_attributes_type', 'loc': ['body'], 'msg': 'Input should be a valid dictionary or object to extract fields from', 'input':  None
422 wrong ctype 422 {'detail': [{'type': 'model_attributes_type', 'loc': ['body'], 'msg': 'Input should be a valid dictionary or object to extract fields from', 'input':  None
401 bearer 401 {'detail': 'Not authenticated'} None
500 resp validation 500 Internal Server Error None
422 form 422 {'detail': [{'type': 'missing', 'loc': ['body', 'username'], 'msg': 'Field required', 'input': None}]} None

(Long bodies are cut at 150 characters by the program.)

A quick diagnostic routine:

  1. Reproduce with curl -i against the app alone (fastapi dev), without the proxy.

  2. Read the status line, then Content-Type (application/problem+json? text/plain from a proxy?) and Server headers: a body that does not look like yours was produced by another layer.

  3. For 422, read loc and type; for 500, read the server log, not the response.

  4. Check /openapi.json or /docs to see what the app thinks the contract is.

  5. Add a regression test with TestClient (Testing).

Pitfalls

  • Returning an HTTPException instead of raising it. return HTTPException(404, "x") is just a return value: the client receives status 200 and a JSON rendering of the exception object (checked in a run). Always raise.

  • HTTPException for server errors. A deliberately raised 500 bypasses the unhandled-error path (no log entry, no re-raise). Raise a domain exception or let the real exception propagate.

  • Catching too broadly. except Exception: raise HTTPException(400, …​) around a call converts bugs into client errors and hides them from the logs. Catch the specific exception you can explain.

  • Registering the handler for the wrong class. Handle StarletteHTTPException, not fastapi.HTTPException, or the 404 and 405 produced by the router keep the default format.

  • Leaking internals. detail=str(exc) of a database error, a validation error’s raw string or a stack trace exposes schema names, paths and library versions.

  • Exceptions in a BackgroundTask happen after the response has gone: the Exception handler can only log them.

  • Changing the contract silently. Altering the 422 body or the error format breaks generated clients; update responses= and the OpenAPI schema at the same time.

  • Inconsistent errors. Mixing \{"detail": "…​"}, \{"message": "…​"} and plain text across routes forces clients to special-case. Decide on one shape and enforce it with handlers.

  • A 204 that returns data or a 201 without Location: valid, but surprising to clients.

  • Expecting the exit code of a yield dependency to change the response. Since 0.118.0 the code after yield runs after the response was sent; see Dependency Injection.

  • Testing only the happy path. Error responses are part of the API; assert on status code and body in tests, and construct TestClient(app, raise_server_exceptions=False) when you want to see a real 500 response.

Where Older Guides Are Outdated, and What Changed Recently

  • Validation error payloads. Guides written for Pydantic v1 show "msg": "value is not a valid integer" with "type": "type_error.integer" or "value_error.missing". Since FastAPI dropped Pydantic v1 (0.126.0, 2025-12-20, and pydantic.v1 in 0.128.0) the bodies have Pydantic v2’s shape ("type": "int_parsing", "missing", "input", "ctx"). Even the official handling-errors page still prints the old v1 example output beside the v2 code, so trust a real run.

  • Domain exceptions. Older guides map them with try/except in every route, or by raising HTTPException from the service layer. The registered-handler approach above keeps the service layer free of HTTP.

  • Dependencies with yield. Before 0.106.0 an HTTPException raised after the yield of a dependency could not become a response; since 0.118.0 the exit code runs after the response has been sent.

  • Security errors. Since 0.122.0 HTTPBearer, OAuth2PasswordBearer and the other security classes answer 401 when credentials are missing; guides from earlier versions say 403.

  • status.HTTP_422_UNPROCESSABLE_ENTITY is deprecated in favour of HTTP_422_UNPROCESSABLE_CONTENT (RFC 9110 naming); both are 422.

  • HTTPException.headers is typed as a Mapping since 0.128.7.

  • Strict Content-Type checking (0.132.0) changed how JSON bodies without a JSON Content-Type fail; see Request Object & Content-Type.

  • Event hooks. Older guides log errors in @app.on_event handlers; use the lifespan context manager (Settings, Lifespan & Background Tasks) and exception handlers.

  • Problem details. RFC 9457 (2023) replaced RFC 7807 as the problem-details standard; the format is the same for practical purposes.