Back to Blog
Python

Python FastAPI Exception Handlers and Custom Errors

python fastapi exception handlers and custom errors: Learn how to define custom exception classes, register exception handlers, and return structured error responses i...

FastAPIException HandlingCustom ExceptionsHTTP ResponsesError Responses
Illustration of a FastAPI exception handler intercepting a custom error and converting it into a structured HTTP response

When building a FastAPI application, you often need to control how errors are returned to clients. Python FastAPI exception handlers and custom errors give you a central place to map exceptions to HTTP responses, so you avoid duplicating error-handling logic in every route. This article explains how FastAPI matches exceptions to handlers, how to define your own exception types, and how to return consistent error payloads while preserving the original failure for logging.

How FastAPI Matches Exceptions to Handlers

FastAPI uses Starlette's exception handling machinery. When an exception is raised during request processing, FastAPI looks for a registered handler for that exception class. If no exact match exists, it walks up the exception class hierarchy until it finds a handler for a parent class. If nothing matches, it falls back to the default handler for Exception or the framework's built-in handlers for HTTPException and RequestValidationError.

This inheritance-based lookup means you can register a handler for a base class and have it catch all subclasses. It also means you should be careful about registering a handler for Exception because it will intercept every unhandled error, including programming bugs.

Defining a Custom Exception Class

A custom exception is just a Python class that inherits from Exception. You can add attributes to carry extra context, such as an error code or a field name. For example, a domain-level error might look like this:

class OrderNotFoundError(Exception): def __init__(self, order_id: int): self.order_id = order_id super().__init__(f"Order {order_id} not found")

The __init__ method stores the order ID and passes a human-readable message to the base class. This message is available via str(exception) and is useful for logging, but it does not automatically become the HTTP response body. The response body is determined by the handler you register.

Registering Exception Handlers with @app.exception_handler

To handle a custom exception, use the @app.exception_handler decorator. The handler function receives the request and the exception instance, and it returns a JSONResponse (or any Response subclass). Here is a handler for OrderNotFoundError:

from fastapi import FastAPI, Request from fastapi.responses import JSONResponse app = FastAPI() @app.exception_handler(OrderNotFoundError) async def order_not_found_handler(request: Request, exc: OrderNotFoundError): return JSONResponse( status_code=404, content={"error": "order_not_found", "order_id": exc.order_id}, )

When an endpoint raises OrderNotFoundError, FastAPI calls this handler and returns the JSON payload. The request object is available if you need to inspect headers, query parameters, or the URL. The exc object gives you access to the attributes you defined.

You can register handlers for any exception class, including built-in ones. For example, you might want to override the default HTTPException behavior to add a custom error code:

from fastapi import HTTPException @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail, "error_code": "http_error"}, )

This handler replaces FastAPI's default HTTPException response. The exc.detail and exc.status_code attributes are always present, but exc.headers may be None if no headers were provided.

Handling HTTPException and RequestValidationError

FastAPI automatically raises HTTPException for common conditions like missing routes or failed authentication. It also raises RequestValidationError when request data fails validation. Both have default handlers that return a 422 response for validation errors and the status code you specify for HTTPException.

If you want a consistent error format across all failures, you should register handlers for both. The RequestValidationError object contains a errors() method that returns a list of validation error details. Here is an example that flattens those details into a single object:

from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): errors = [ {"field": err["loc"], "message": err["msg"]} for err in exc.errors() ] return JSONResponse( status_code=422, content={"error": "validation_error", "details": errors}, )

Note that exc.errors() returns a list of dictionaries with keys like loc, msg, type, and sometimes ctx. The loc value is a tuple of strings and integers that indicates where the error occurred, such as ("body", "quantity"). You can format this however you like, but keeping the raw structure is often more useful for clients that need to map errors to fields.

Returning Structured Error Responses

A common pattern is to return a consistent envelope for all errors. Instead of letting each handler decide its own shape, you can define a helper function that builds the response. For example:

def error_response(status_code: int, error_code: str, message: str, **extra): return JSONResponse( status_code=status_code, content={"error": error_code, "message": message, **extra}, )

Then each handler can call this helper. This keeps the response format uniform across different exception types. It also makes it easier for API clients to parse errors because they only need to handle one structure.

When you design the error envelope, think about what information a client actually needs. A machine-readable error code is useful for programmatic handling, while a human-readable message helps developers debug. Avoid leaking internal stack traces or database details into the response body; those belong in server logs.

Logging and Preserving the Original Exception

Exception handlers are a good place to log errors, but you must be careful not to lose the original traceback. If you catch an exception and return a generic response without re-raising it, the traceback is lost unless you log it explicitly. Use Python's logging module to record the exception details before returning the response:

import logging logger = logging.getLogger(__name__) @app.exception_handler(OrderNotFoundError) async def order_not_found_handler(request: Request, exc: OrderNotFoundError): logger.info("Order not found: %s", exc.order_id) return error_response(404, "order_not_found", str(exc), order_id=exc.order_id)

For unexpected errors, you might want to log at ERROR level with the full traceback. Use logger.exception("Unhandled error", exc_info=exc) to capture the stack trace. But be careful: if you register a handler for Exception, it will catch every unhandled error, including those you might want to let propagate to a monitoring system. In production, you may prefer to let the default handler deal with truly unexpected errors and only handle known exception types.

Performance and Maintainability Considerations

Exception handlers add a small amount of overhead because FastAPI must look up the handler for each exception. In practice, this is negligible compared to the cost of serializing a response or hitting a database. The bigger concern is maintainability. If you register many handlers with overlapping responsibilities, it becomes hard to predict which one will run.

Keep handlers focused on one exception type or a small hierarchy. If you need to apply common logic to multiple handlers, extract that logic into a helper function rather than using a broad Exception handler. Also remember that handlers are resolved by class inheritance, so registering a handler for a base class will catch all subclasses. This is useful for grouping related errors, but it can surprise you if you later add a subclass that needs different behavior.

Another maintainability issue is response format consistency. If you have multiple handlers returning different JSON shapes, clients will struggle to handle errors uniformly. Define a single error schema and enforce it across all handlers. FastAPI's response model validation does not apply to exception handlers, so you are responsible for keeping the output consistent.

Testing Exception Handlers

You can test exception handlers directly by calling the handler function with a mock request and exception, or by using FastAPI's TestClient to simulate a request that raises the exception. The TestClient approach is more realistic because it exercises the full routing and middleware stack. For example:

from fastapi.testclient import TestClient client = TestClient(app) def test_order_not_found(): response = client.get("/orders/999") assert response.status_code == 404 assert response.json() == {"error": "order_not_found", "order_id": 999}

When you test handlers, verify both the status code and the response body. Also test that the handler does not accidentally swallow exceptions that should be re-raised. If your handler logs an error and returns a generic response, make sure the log output contains enough context to diagnose the issue.

A subtle edge case occurs when an exception is raised inside a background task or a dependency. FastAPI's exception handlers only apply to exceptions raised during request handling. If a background task raises an exception, it will not be caught by the request's exception handler; you need to handle it inside the task itself. Similarly, exceptions raised in a dependency that is used with yield may behave differently depending on how the dependency is implemented. Test these paths explicitly to avoid surprises.

Final Code Example: Combining Custom Errors and Handlers

To bring the pieces together, here is a complete example that defines a custom exception, registers a handler, and uses it in an endpoint:

from fastapi import FastAPI, Request from fastapi.responses import JSONResponse class ItemNotFoundError(Exception): def __init__(self, item_id: str): self.item_id = item_id super().__init__(f"Item {item_id} not found") app = FastAPI() @app.exception_handler(ItemNotFoundError) async def item_not_found_handler(request: Request, exc: ItemNotFoundError): return JSONResponse( status_code=404, content={"error": "item_not_found", "item_id": exc.item_id}, ) @app.get("/items/{item_id}") async def get_item(item_id: str): if item_id != "known": raise ItemNotFoundError(item_id) return {"item_id": item_id, "name": "Known Item"}

This endpoint returns a 404 with a structured error body when the item is not found. The handler is registered globally, so any other endpoint that raises ItemNotFoundError will receive the same response format. This is the core value of Python FastAPI exception handlers and custom errors: they let you centralize error handling while keeping your route logic clean and focused on the happy path.

python fastapi exception handlers and custom errors: Practic | RYUSLOG DEV