Error Handling and Global Exceptions in FastAPI
Centralised error handling is the practice of converting every way a service can fail — a rejected body, a raised HTTPException, a violated business rule, an unforeseen crash — into one predictable response through a small set of registered handlers, instead of scattering try/except through route code.
The error contract is part of the published surface of an API, which is why it belongs in Core Architecture and Routing Patterns beside routing and lifecycle rather than being treated as an implementation detail. Handlers are registered in the application factory, the request identifier they stamp into failures comes from middleware, and the codes they emit are what observability and tracing alerts on. This page is about the design of the contract and the boundaries of the mechanism; the individual builds have their own guides.
Prerequisites
You should know how to raise HTTPException and roughly where FastAPI's validation errors come from. The runs below are on FastAPI 0.139.2, Pydantic 2.13.4 and Python 3.12.
Core mechanics: a registry consulted on the way out
FastAPI keeps a mapping from exception class to handler function. When an exception propagates out of a route, the mapping is consulted for the exception's class and then its ancestors, so the most specific registered handler wins and a base class handler covers everything beneath it. This is why a small hierarchy of domain errors needs one handler rather than one per class, and it is why the handler you register for Exception behaves as a catch-all.
Two structural details matter more than the lookup itself.
The first is that this registry is consulted at a fixed place in the stack — inside routing, beneath your middleware. That position is what decides the mechanism's reach, and it is worth measuring rather than assuming.
The second is that HTTPException is not privileged. FastAPI registers a default handler for it exactly as you would register your own, which means replacing that handler is a supported operation rather than a workaround. The same is true of RequestValidationError: the shape you see by default is a handler's output, not a hard-coded response.
Production implementation: measuring the reach
This service registers a handler for a typed DomainError and another for bare Exception, then fails in four different places. Only some of them produce the contract the handlers describe.
@service.exception_handler(DomainError)
async def handle_domain(request: Request, exc: DomainError) -> JSONResponse:
return JSONResponse(status_code=exc.status, content={"error": {"code": exc.code}})
@service.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content={"error": {"code": "internal_error"}})
class FragileMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.headers.get("x-break-middleware") == "yes":
raise RuntimeError("middleware exploded")
if request.headers.get("x-domain-fail-middleware") == "yes":
# A typed error the service has a specific handler for — raised one layer too far out.
raise DomainError(code="rate_limited", status=429)
return await call_next(request)
@service.get("/mid-stream")
async def mid_stream() -> StreamingResponse:
async def body():
yield b"first-chunk "
raise DomainError(code="too_late", status=409)
return StreamingResponse(body(), media_type="text/plain")
The recorded result:
$ GET /reach
200 OK
{
"raised_in_the_endpoint": {
"request": "/in-the-endpoint",
"status": 404,
"content_type": "application/json",
"body": "{\"error\":{\"code\":\"order_not_found\"}}"
},
"raised_in_middleware": {
"request": "/in-middleware",
"status": 500,
"content_type": "application/json",
"body": "{\"error\":{\"code\":\"internal_error\"}}"
},
"typed_error_raised_in_middleware": {
"request": "/in-middleware",
"status": 500,
"content_type": "application/json",
"body": "{\"error\":{\"code\":\"internal_error\"}}"
},
"raised_after_streaming_started": {
"request": "/mid-stream",
"status": 200,
"content_type": "text/plain; charset=utf-8",
"body": "first-chunk "
}
}
The third case is the one to study. A DomainError carrying a 429 was raised in middleware, and the service has a handler registered for exactly that class — yet the response is a 500 with the generic code. The specific handler lives inside routing and the failure happened outside it, so only the outermost boundary saw anything. The practical rule that falls out: middleware should return a response for conditions it wants to signal, never raise, because raising discards the type information your contract is built on. Where that boundary sits relative to each of your layers is developed in middleware execution order.
The fourth case is worse and quieter. Once a streaming response has emitted its status line, nothing can be rewritten. The client receives a 200 and a body that simply stops, and unless it independently verifies completeness — a length, a terminator, a checksum — it will treat a truncated export as a complete one. Everything a streaming endpoint might refuse must therefore be checked before the first chunk is yielded, a constraint discussed further in streaming and file responses.
Production implementation: testing the contract as an interface
If the error envelope is part of your published interface, the test that matters is not "does this route return 404" but "does every way this service can fail produce the same shape". That is a conformance check, and it is short enough to keep permanently.
SURFACES = [
("unmatched route", "GET", "/no-such-path", None),
("method not allowed", "DELETE", "/orders", None),
("request validation", "POST", "/orders", {"quantity": 0}),
("HTTPException in handler", "GET", "/orders/9", None),
("domain rule violated", "POST", "/orders", {"quantity": 500}),
("uncaught exception", "GET", "/crash", None),
]
@service.exception_handler(StarletteHTTPException)
async def on_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
# Registered on the Starlette class, so unmatched routes and 405s land here too.
return envelope(f"http_{exc.status_code}", str(exc.detail), request, exc.status_code)
Run against a service with four handlers registered — domain errors, the Starlette HTTP exception, validation errors, and the catch-all — the check reports:
$ GET /conformance
200 OK
{
"surfaces": [
{
"surface": "unmatched route",
"status": 404,
"json": true,
"code": "http_404",
"conforms": true
},
{
"surface": "method not allowed",
"status": 405,
"json": true,
"code": "http_405",
"conforms": true
},
{
"surface": "request validation",
"status": 422,
"json": true,
"code": "validation_error",
"conforms": true
},
{
"surface": "HTTPException in handler",
"status": 404,
"json": true,
"code": "http_404",
"conforms": true
},
{
"surface": "domain rule violated",
"status": 409,
"json": true,
"code": "out_of_stock",
"conforms": true
},
{
"surface": "uncaught exception",
"status": 500,
"json": true,
"code": "internal_error",
"conforms": true
}
],
"distinct_top_level_shapes": 1,
"all_surfaces_conform": true
}
Four handlers cover six surfaces with one shape, and each failure keeps the status code it deserves — the domain violation is a 409 rather than being flattened into a generic 400. Two details in that arrangement are load-bearing. The OutOfStock class inherits from DomainError and needs no handler of its own, which is what keeps adding an error type cheap. And the HTTP handler is registered on Starlette's exception class rather than FastAPI's subclass, which is the only reason the unmatched route and the unsupported method appear in the conforming column at all; the four-handler surface and the shapes it replaces are detailed in global exception handlers for consistent API responses.
Designing the contract itself
Two decisions sit upstream of any of this code, and getting them right matters more than the handler mechanics.
The first is what to raise. A failure that is genuinely an HTTP fact — this path needs authentication, this method is not allowed here — is reasonably expressed as an HTTPException at the edge. A failure that is a business rule is not an HTTP fact at all, and expressing it as one couples the code that enforces the rule to the web layer, so the same function cannot be reused from a scheduled job or a queue consumer without carrying a status code that means nothing there. The full argument, including how to keep a service layer free of framework imports, is in HTTPException vs custom exception classes.
The second is what a client is promised. A stable machine-readable code is the most valuable field in the envelope, because it is the only part a client can branch on safely; a message is for humans and will be reworded. Publish codes deliberately and treat them as immutable once released. Include the request identifier so a user's screenshot becomes something you can look up. Include structured context where a client can act on it — knowing how many units are actually available is useful in a way that a prose sentence is not. And omit anything derived from the exception's own message, which is the field most likely to carry a value the caller sent, including one they should never see echoed back.
Validation failures deserve a deliberate decision rather than a default. FastAPI's own 422 shape is well-designed and detailed; the cost of replacing it is that you now own the wording forever, and the cost of leaving it is that clients parse two shapes. Either answer is defensible, and the mechanics of the first are in customising validation error responses.
For an API crossing organisational boundaries, consider RFC 9457 problem details before inventing an envelope. Its type, title, status and detail fields cover most of what a custom shape reinvents, and a consumer may already have tooling for it.
Choosing status codes that mean something
The status code is the part of the contract that intermediaries act on, so it deserves more thought than it usually gets. Load balancers, retry libraries, browser caches and monitoring systems all branch on it without reading your body, which means an inaccurate code causes behaviour you did not intend somewhere you cannot see.
The distinction that matters most is between a caller who can fix the problem and one who cannot. A 4xx says the request was wrong and repeating it unchanged will fail again; a 5xx says the service failed and the same request might succeed later. Getting this backwards is costly in both directions. A business-rule violation returned as 500 pollutes your error budget, wakes somebody up, and invites clients to retry something that will never succeed. A genuine internal fault returned as 400 hides an outage from every dashboard you own, because nobody alerts on client errors.
Within 4xx, prefer the specific code that describes the situation. A conflicting state is 409 rather than 400, because a client can distinguish "you sent nonsense" from "the world changed underneath you" and act differently. A missing resource is 404. A well-formed request the caller is not permitted to make is 403, distinct from 401, which says the caller was not identified at all — a difference that decides whether a client should prompt for credentials or give up. Rate limiting is 429 and should carry a Retry-After header, because that header is what turns a client's blind retry loop into cooperative backoff.
Resist the urge to encode fine distinctions in the status code itself. That is what the machine-readable code in the body is for, and it can carry as much specificity as you like without confusing an intermediary. The pairing to aim for is a broadly correct status code that infrastructure understands, and a precise code in the envelope that your own clients and dashboards branch on.
Async and performance notes
Handlers run on the event loop on the failure path, which is often the busiest path you have during an incident. Keep them to building a response and emitting one log record. A handler that writes to a database adds latency to every failure exactly when the dependency it is writing to may be the thing that is failing, and a handler that awaits an outbound call can turn a partial outage into a total one. Where failures must be persisted, hand them to a queue as covered in background task processing.
Serialisation deserves attention because it is a real source of self-inflicted 500s. Error payloads frequently embed values the caller submitted, and those are not guaranteed to be JSON-encodable — a decimal, a date, a set, an out-of-range float. Passing them to a response without encoding them first fails inside the handler, and a handler that raises produces a bare response from the outer boundary, losing the envelope precisely when it is needed.
Bound the size of anything you echo. A deeply nested payload submitted with many invalid entries produces a proportionally large error list, each entry embedding its own offending input. Truncate to a sensible number of entries and say that you did.
Testing strategy
The conformance check above is the backbone. Beyond it, three assertions are worth keeping.
Assert the content type, not only the status. The regression that hurts most is a failure returning correctly-numbered plain text, because clients that call .json() unconditionally turn a server error into a client-side crash, and a status-only assertion passes throughout.
Assert that nothing leaks. Trigger a failure whose exception message contains a recognisable sentinel and assert that string is absent from the response body. This is cheap and it catches the day someone adds str(exc) to a handler while debugging.
Assert that codes are unique. Walk the subclasses of your domain error base and check no two declare the same code, since duplicated codes quietly merge two distinct conditions in every dashboard and alert built on them.
Failure modes and diagnosis
A 500 arrives as plain text. The failure escaped the handler registry entirely — either raised in middleware, or raised by a handler. Check for an exception logged with no matching envelope.
A typed error produced a generic 500. It was raised outside routing. Middleware should return responses rather than raise.
A truncated response reports success. An exception occurred after streaming began. Validate before the first chunk, and give the client a way to detect completeness.
Unmatched routes return a different shape. The handler was registered on FastAPI's HTTPException rather than Starlette's parent class. Register on the parent.
The handler itself raises. Something in the payload is not encodable. Encode with jsonable_encoder before constructing the response.
Alerts fire on status codes and tell you nothing. Both a caller sending nonsense and a genuine product signal are 4xx. Alert on the machine-readable code instead, which distinguishes them.
A catch-all masks real failures. A handler returning 200 for unexpected errors makes an outage invisible to monitoring. Preserve the status.
Where a failure should be raised
| Inside the endpoint or a dependency | Inside middleware | After streaming begins | |
|---|---|---|---|
| Specific handlers apply | Yes | No | No |
Catch-all Exception handler applies | Yes | Yes | No |
| Status code controllable | Yes | Only 500 | No |
| Envelope preserved | Yes | Generic only | None |
| Correct action | Raise a typed error | Return a response | Validate beforehand |
FAQ
Can an exception handler catch something raised inside my middleware?
Only the handler registered for Exception can, because FastAPI installs that one on the outermost error boundary. A handler registered for a specific class never fires for a middleware failure, so a typed domain error raised in middleware still produces a generic 500.
What happens if an error occurs after a streaming response has started? Nothing useful. The status line and headers were already sent, so no handler can change them. The client receives a 200 with a truncated body and must detect the truncation itself, which is why streaming endpoints should validate everything before the first chunk is yielded.
Should I register handlers for fastapi.HTTPException or starlette.exceptions.HTTPException?
The Starlette class. FastAPI's HTTPException is a subclass of it, and Starlette raises the parent class directly for unmatched routes and unsupported methods, so a handler registered on the subclass never matches those responses.
Should error responses include the exception message? Not the raw message. It can carry SQL fragments, file paths or values the caller submitted, including credentials. Return a fixed message with a stable machine-readable code, and log the detail internally against the request identifier.
Is a custom envelope better than RFC 9457 problem details? For a public or cross-organisation API, problem details is usually the better choice because clients may already understand it. A custom envelope is reasonable for an internal API where you control every consumer and want fields the standard does not define.
Why does my exception handler itself cause a 500? Because it raised. A handler that serialises an object Pydantic cannot encode, or that reaches a database, fails on the exact path that exists to prevent failures. Keep handlers to building a response and emitting one log line.
Related reading
- Up a level to Core Architecture and Routing Patterns for how the error contract relates to routing, configuration and middleware.
- Global exception handlers for consistent API responses enumerates the default failure surface and the four handlers that replace it.
- HTTPException vs custom exception classes settles what to raise and how to keep status codes out of a service layer.
- Customising validation error responses covers reshaping the 422 without losing the detail that makes it useful.
- Middleware implementation supplies the request identifier these handlers stamp into every failure.
- Observability and tracing is where the codes emitted here become alerts.