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 |
|
Handled error |
Your code (or a dependency) raises |
|
Validation error |
FastAPI cannot convert or validate the request, so it raises |
A handler for |
Unhandled error |
Any other exception escapes your code (a bug), or the response violates its |
A handler for |
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"]))
Source: FastAPI: Response Status Code
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 |
|---|---|---|
|
Informational |
Handled by the server; you rarely use them directly. Responses with these codes cannot have a body. |
|
Success |
|
|
Redirection |
|
|
The client made a mistake |
|
|
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 |
|---|---|---|
|
200 |
Read, list, full update returning the resource. |
|
201 |
|
|
202 |
Accepted, processed later (for example a cover-image conversion job). |
|
204 |
|
|
400 |
Generic client error not covered by a more precise code. |
|
401 |
Missing or invalid credentials (send |
|
403 |
Authenticated but not allowed. |
|
404 |
The book, author or review does not exist. |
|
409 |
The request conflicts with current state (duplicate ISBN). |
|
410 |
A resource that existed and was deliberately removed. |
|
413 |
An upload exceeds the limit. |
|
415 |
The request |
|
422 |
Syntactically fine, semantically invalid; FastAPI’s validation response. |
|
429 |
Rate limit reached (send |
|
500 |
Unhandled error. |
|
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 Createdtells the client that a new resource exists. By convention the response contains the new resource (so the client learns the generatedid) and aLocationheader with the URL of the new resource. -
204 No Contenttells the client that the operation succeeded and that there is nothing to send back. A204response 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→ Noneso that the type hints tell the truth too. This is the usual answer forDELETE.
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())
Source: FastAPI: Response Status Code
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 forPOST. FastAPI does not guess201from the verb. Forgettingstatus_code=201on 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 calledstatus_code. -
A
204with a body. The body is dropped silently; if a client reports that "my response is empty", check whether a204(or304, or a1xx) 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
200or201, the second code is invisible in OpenAPI unless you declare it withresponses=(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())
Source: FastAPI: Handling Errors
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 |
|---|---|---|
|
|
The HTTP status code to send. Required. |
|
any JSON-compatible value |
Placed under the |
|
|
Extra response headers. Needed by some protocols: |
Points worth knowing:
-
The body shape is fixed. The default handler always answers
\{"detail": …}. Anything else (an error code, aninstance, a list of problems) has to be insidedetail, or you override the handler (below). -
Use it for client errors. The API reference says it plainly:
HTTPExceptionis for client errors, invalid authentication and invalid data, not for server errors in your code. Do not raiseHTTPException(500)to report a bug; let the exception propagate so that it is logged (see "Unhandled errors and `500`"). -
401needsWWW-Authenticate. HTTP requires the header on a401; the security classes of FastAPI send it for you (see Security Fundamentals). Since 0.122.0 they answer401rather than403when credentials are missing. -
Dependencies and
yield. A dependency can raiseHTTPExceptionbefore it yields, and since 0.106.0 also after theyield; 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_bookmay answer404. Declare it withresponses=(see "Documenting Error Responses"). -
Two classes, one name.
fastapi.HTTPExceptionextendsstarlette.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())
Source: FastAPI: Handling Errors
{'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 theexception_handlers=\{…}dictionary argument ofFastAPI(…).
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())
Source: FastAPI: Handling Errors
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.
APIRouterhas neitherexception_handlernoradd_exception_handler; register handlers on theFastAPIinstance (a mounted sub-application has its own handlers, see Sub-Applications, WSGI & GraphQL). -
Handlers can use the request. The
requestargument exposes the URL, headers andrequest.state, which is how the example fills thepathfield. 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; useJSONResponse(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())
Source: FastAPI: Handling Errors
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
404serves everyHTTPExceptioncarrying 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 ownHTTPException(404, …). -
In debug mode Starlette responds with a traceback page instead of the
500handler. Never enable it in production. -
An exception in a
BackgroundTaskreaches theExceptionhandler 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())
Source: FastAPI: Handling Errors
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())
Source: FastAPI: Handling Errors
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_encoderwhen you passexc.errors()through. Actxcan contain the original exception object (for a failingValueErrorin a validator the object is inctx["error"]), whichJSONResponsecannot serialise. The default handler appliesjsonable_encoderfor this reason. Building the strings yourself, as the example does, avoids the problem. -
Do not return
str(exc). The string form of aRequestValidationErrorincludes 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.bodyhelps 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
400for everything the client got wrong. That is legitimate, but then OpenAPI still documents422; change the documented code with the application’sresponsesor accept the mismatch knowingly. -
Replacing is global. A handler for
RequestValidationErrorapplies 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())
Source: FastAPI: Handling Errors
{'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)
Source: FastAPI: Handling Errors
{'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"]))
Source: FastAPI: Additional Status Codes
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 default200) is the value used unless you assign another one. -
The
Responseparameter 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,409and500under the operation and show theErrorBodyschema; client generators produce typed error classes from them. -
A response without a
descriptiongets 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_FOUNDin one module and merge them with**, as above. Aresponsesvalue onAPIRouter(…)orFastAPI(…)applies to all its routes; the key"default"describes "any other status". -
For the
422of validation errors FastAPI documents the schemaHTTPValidationErrorfor you. If you replace the handler with a different body shape, document your shape withresponses=\{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 |
|---|---|
|
A URI that identifies the kind of problem; when omitted, |
|
A short, human-readable summary of the problem type; it should not vary from occurrence to occurrence. |
|
The HTTP status code (a convenience for consumers that lose the status line). |
|
An explanation specific to this occurrence. |
|
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:
-
ProblemResponseis aJSONResponsesubclass that only changesmedia_type; the same trick works for any custom media type, see Custom Responses. -
The handler for
StarletteHTTPExceptioncopiesexc.headersto the response, soWWW-AuthenticateandAllowsurvive the conversion (noteBearerin 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
typeURIs are identifiers; they do not have to be resolvable, although a human-readable page there is the point of the format. Theexample.comdomain 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 theAcceptheader. -
Spring and ASP.NET Core have built-in support for the same format (
ProblemDetailandProblemDetails); 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 |
|---|---|---|
|
A route declared with a trailing slash ( |
Call the exact path; make clients follow redirects with the method preserved; fix proxy headers (see Middleware, CORS & Proxies). |
|
Not produced by FastAPI validation (that is |
Read the |
|
Missing or invalid credentials. Since 0.122.0 the built-in security classes answer |
Is the |
|
Authenticated but not authorised: missing scope or role, or a CORS or CSRF rule in front. Older FastAPI versions also used |
Check the scopes in the token against the route’s requirements; check the proxy or WAF. |
|
No route matches (typo, missing prefix of the |
Open |
|
The path exists but not for that method; the |
Compare the method with the OpenAPI document; read |
|
Not produced by FastAPI itself; a proxy or your own content negotiation. |
Review the |
|
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. |
|
Not produced by FastAPI or Starlette for ordinary bodies. Comes from the proxy ( |
Raise the proxy limit, or enforce your own explicit limit while streaming (see Forms & File Uploads). |
|
Raised by your own code or a proxy. A JSON body sent with the wrong |
See the next rows. |
|
The most common FastAPI error. Missing field ( |
Read |
|
Rate limiting in a gateway or in your own dependency/middleware; FastAPI has none built in. |
Look for |
|
An unhandled exception, or a response that violates its |
Find the traceback in the server log (quote the |
|
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. |
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:
-
Reproduce with
curl -iagainst the app alone (fastapi dev), without the proxy. -
Read the status line, then
Content-Type(application/problem+json?text/plainfrom a proxy?) andServerheaders: a body that does not look like yours was produced by another layer. -
For
422, readlocandtype; for500, read the server log, not the response. -
Check
/openapi.jsonor/docsto see what the app thinks the contract is. -
Add a regression test with
TestClient(Testing).
Pitfalls
-
Returning an
HTTPExceptioninstead of raising it.return HTTPException(404, "x")is just a return value: the client receives status200and a JSON rendering of the exception object (checked in a run). Alwaysraise. -
HTTPExceptionfor server errors. A deliberately raised500bypasses 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, notfastapi.HTTPException, or the404and405produced 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
BackgroundTaskhappen after the response has gone: theExceptionhandler can only log them. -
Changing the contract silently. Altering the
422body or the error format breaks generated clients; updateresponses=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
204that returns data or a201withoutLocation: valid, but surprising to clients. -
Expecting the exit code of a
yielddependency to change the response. Since 0.118.0 the code afteryieldruns 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 real500response.
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, andpydantic.v1in 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/exceptin every route, or by raisingHTTPExceptionfrom the service layer. The registered-handler approach above keeps the service layer free of HTTP. -
Dependencies with
yield. Before 0.106.0 anHTTPExceptionraised after theyieldof 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,OAuth2PasswordBearerand the other security classes answer401when credentials are missing; guides from earlier versions say403. -
status.HTTP_422_UNPROCESSABLE_ENTITYis deprecated in favour ofHTTP_422_UNPROCESSABLE_CONTENT(RFC 9110 naming); both are422. -
HTTPException.headersis typed as aMappingsince 0.128.7. -
Strict
Content-Typechecking (0.132.0) changed how JSON bodies without a JSONContent-Typefail; see Request Object & Content-Type. -
Event hooks. Older guides log errors in
@app.on_eventhandlers; 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.