How to Add Custom Middleware to a FastAPI Application in Python

Middleware is the code that runs on every request before it reaches your route handler and on every response before it goes back to the client. It is the right place for cross-cutting concerns—logging, request timing, error handling, security headers, CORS configuration—that apply across all or many endpoints. FastAPI gives you two approaches for building custom middleware: a simple function-based decorator and a class-based pattern using Starlette's BaseHTTPMiddleware. This guide covers both, shows practical examples of each, and explains the execution order and performance trade-offs you need to know for production.

How Middleware Works in FastAPI

Every middleware wraps the application like a layer in an onion. When a request arrives, it passes through each middleware layer from the outside in. The innermost layer calls your actual route handler. Then the response travels back out through each layer in reverse order. Each middleware can inspect or modify both the request and the response, or short-circuit the chain entirely by returning a response early.

FastAPI is built on top of Starlette, and all Starlette middleware works with FastAPI directly. This means you have access to a rich ecosystem of existing middleware (CORS, GZip compression, trusted hosts) alongside anything custom you build yourself.

Function-Based Middleware With the Decorator

The simplest way to create middleware is with the @app.middleware("http") decorator. The function receives the incoming Request and a call_next function that passes the request to the next layer.

import time
from fastapi import FastAPI, Request

app = FastAPI()

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    start_time = time.perf_counter()
    response = await call_next(request)
    process_time = time.perf_counter() - start_time
    response.headers["X-Process-Time"] = f"{process_time:.4f}"
    return response

Everything before await call_next(request) runs on the request path. Everything after runs on the response path. This pattern is ideal for simple, single-purpose middleware where you do not need initialization parameters or internal state.

Note

Use time.perf_counter() instead of time.time() for measuring request duration. It provides higher resolution and is not affected by system clock adjustments.

Class-Based Middleware With BaseHTTPMiddleware

For middleware that needs configuration options, internal state, or more complex logic, extend Starlette's BaseHTTPMiddleware class. Override the dispatch method with your middleware logic, and use __init__ to accept configuration parameters.

from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

class RequestIDMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, header_name: str = "X-Request-ID"):
        super().__init__(app)
        self.header_name = header_name

    async def dispatch(self, request: Request, call_next) -> Response:
        import uuid
        request_id = request.headers.get(self.header_name, str(uuid.uuid4()))
        # Store on request.state so route handlers can access it
        request.state.request_id = request_id
        response = await call_next(request)
        response.headers[self.header_name] = request_id
        return response

app = FastAPI()
app.add_middleware(RequestIDMiddleware, header_name="X-Request-ID")

The class accepts a configurable header name through __init__. The dispatch method checks whether the client sent a request ID; if not, it generates one. The ID is stored on request.state for use in route handlers and attached to the response header for the client. Register the middleware using app.add_middleware() and pass configuration as keyword arguments.

Approach Best For Registration
Function-based (@app.middleware) Simple, stateless logic (timing, single header) Decorator on function
Class-based (BaseHTTPMiddleware) Configurable, stateful logic (logging, rate limiting) app.add_middleware(ClassName, **kwargs)

Practical Example: Request Logging

Structured request logging is one of the first things to add to a production API. This middleware logs the method, path, status code, and processing time for every request.

import logging
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

logger = logging.getLogger("api")

class RequestLoggingMiddleware(BaseHTTPMiddleware):
    SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key"}

    async def dispatch(self, request: Request, call_next) -> Response:
        start = time.perf_counter()
        response = await call_next(request)
        duration = time.perf_counter() - start

        logger.info(
            "request completed",
            extra={
                "method": request.method,
                "path": request.url.path,
                "status": response.status_code,
                "duration_ms": round(duration * 1000, 2),
                "client": request.client.host if request.client else "unknown",
            },
        )
        return response

The SENSITIVE_HEADERS set is there as a reminder: if you extend this to log request headers, filter out sensitive values like authorization tokens and cookies. Never log credentials.

Practical Example: Centralized Error Handling

Middleware can catch unhandled exceptions and return a consistent error response instead of letting a raw 500 error reach the client.

import traceback
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

class ErrorHandlingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            return await call_next(request)
        except Exception as exc:
            logger.error(
                "unhandled exception",
                extra={
                    "path": request.url.path,
                    "error": str(exc),
                    "traceback": traceback.format_exc(),
                },
            )
            return JSONResponse(
                status_code=500,
                content={"detail": "An internal error occurred."},
            )

This catches any exception that was not handled by your route handlers or FastAPI's built-in exception handlers. The error details go to the log; the client receives a clean JSON response without stack traces or implementation details.

Common Mistake

Never return raw exception messages or stack traces to the client in production. Log the details server-side and return a generic error message to the user. Exposing internal errors is both a security risk and a poor user experience.

Practical Example: Security Headers

Adding security headers to every response helps protect against common web vulnerabilities like cross-site scripting (XSS) and clickjacking.

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["X-XSS-Protection"] = "1; mode=block"
        response.headers["Strict-Transport-Security"] = (
            "max-age=63072000; includeSubDomains"
        )
        return response

These headers tell browsers to prevent MIME-type sniffing, block framing (which prevents clickjacking), enable the XSS filter, and enforce HTTPS connections. Adding them in middleware guarantees they appear on every response regardless of which route handler generated it.

Using request.state to Pass Data Between Layers

The request.state object is a namespace where middleware can store values that route handlers or other middleware can read later. This is useful for passing a request ID, a timestamp, or a resolved user object from middleware into the handler without modifying function signatures.

@app.middleware("http")
async def attach_request_metadata(request: Request, call_next):
    import uuid
    request.state.request_id = str(uuid.uuid4())
    request.state.start_time = time.perf_counter()
    response = await call_next(request)
    return response

@app.get("/items/")
def list_items(request: Request):
    request_id = request.state.request_id
    return {"request_id": request_id, "items": []}

Any middleware that runs before your handler can write to request.state, and the handler can read from it. This avoids the need for global variables or thread-local storage.

Middleware Execution Order

Middleware execution order is one of the details that trips up developers. In FastAPI, middleware forms a stack: the last middleware added is the outermost layer.

# Added first  = innermost (runs last on request, first on response)
app.add_middleware(ErrorHandlingMiddleware)

# Added second = middle layer
app.add_middleware(RequestLoggingMiddleware)

# Added last   = outermost (runs first on request, last on response)
app.add_middleware(SecurityHeadersMiddleware)

On an incoming request, the execution order is: SecurityHeadersMiddleware → RequestLoggingMiddleware → ErrorHandlingMiddleware → route handler. On the response path, the order reverses. This means your timing middleware should be the outermost layer if you want it to capture the total processing time including all other middleware.

Pro Tip

A practical rule of thumb: add error handling middleware first (so it is innermost and catches exceptions from all layers above it), then add logging and timing middleware last (so they are outermost and capture the full request lifecycle).

Built-In Middleware: CORS, Trusted Hosts, GZip

FastAPI includes several commonly needed middleware components from Starlette. You do not need to build these yourself.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.gzip import GZipMiddleware

app = FastAPI()

# Allow cross-origin requests from your frontend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourfrontend.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Reject requests with unexpected Host headers
app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=["yourapi.com", "*.yourapi.com"],
)

# Compress responses larger than 500 bytes
app.add_middleware(GZipMiddleware, minimum_size=500)

CORSMiddleware is the one you will use in nearly every project that serves a frontend. TrustedHostMiddleware prevents HTTP host header attacks. GZipMiddleware compresses responses to reduce bandwidth. All three are configured through app.add_middleware() with keyword arguments—the same pattern you use for custom middleware classes.

Performance Considerations

Middleware runs on every request. That makes it important to keep each middleware layer fast and non-blocking.

  • Keep middleware async. Synchronous blocking calls inside middleware stall the event loop and prevent other requests from being processed. If you need to perform I/O (like writing to a log file), use an async-compatible library or offload the work to a background task.
  • Minimize the number of middleware layers. Each layer adds overhead to the request/response cycle. Benchmark showed that adding middleware to a FastAPI application introduces measurable latency under high concurrency. Combine related logic into a single middleware class rather than adding five separate layers.
  • Avoid reading the request body in middleware. Consuming the body in middleware means it must be buffered and re-attached for the route handler to read. This adds memory pressure and complexity. If possible, handle body-dependent logic in dependencies rather than middleware.
  • Use dependencies when the concern is route-specific. Middleware runs on every request. If the logic only applies to certain routes, a dependency is a better fit. Reserve middleware for truly global concerns.

Frequently Asked Questions

What is middleware in FastAPI?

Middleware in FastAPI is a function or class that intercepts every HTTP request before it reaches your route handler and every response before it returns to the client. It is the right tool for cross-cutting concerns—logging, timing, authentication, CORS headers, error handling—that should apply across all or many endpoints without being duplicated in each handler.

What is the difference between function-based and class-based middleware in FastAPI?

Function-based middleware uses the @app.middleware("http") decorator and is best for simple, stateless tasks like adding a single response header. Class-based middleware extends Starlette's BaseHTTPMiddleware, supports configurable parameters through __init__, and is better suited for complex logic like structured logging, rate limiting, or error handling.

Does middleware execution order matter in FastAPI?

Yes. The last middleware added is the outermost layer and runs first on incoming requests. On the response path, it runs last. This means timing middleware should be added last so it wraps everything and captures the full processing duration. Error handling middleware should be added first so it is innermost and can catch exceptions from all layers above it.

When should I use middleware vs. a dependency in FastAPI?

Use middleware for logic that applies globally to every request: timing, logging, security headers, CORS. Use dependencies for logic that applies to specific routes or groups of routes: authentication, pagination, database sessions. Middleware runs unconditionally; dependencies are declared per-route or per-router and support FastAPI's dependency_overrides for testing.

Key Takeaways

  1. Two approaches, one pattern: Function-based middleware uses a decorator for simple cases. Class-based middleware extends BaseHTTPMiddleware for configurable, stateful logic. Both follow the same request → call_next → response flow.
  2. Order matters: The last middleware added is outermost and runs first on requests. Plan your middleware stack so timing wraps everything and error handling catches exceptions from all layers.
  3. Use request.state for inter-layer communication: Store request IDs, timestamps, or resolved metadata on request.state so route handlers can access values set by middleware without modifying their signatures.
  4. Keep middleware fast and async: Middleware runs on every request. Avoid blocking I/O, minimize the number of layers, and use dependencies instead of middleware for route-specific concerns.
  5. Use built-in middleware for common needs: CORS, GZip compression, and trusted host validation are already available from Starlette. Configure them with app.add_middleware() rather than reimplementing them.

Custom middleware gives you a clean, centralized place to handle the concerns that span your entire API. Logging, timing, security headers, and error handling all belong here because they apply to every request. By keeping each middleware layer focused on a single responsibility and understanding the execution order, you build a middleware stack that adds observability and resilience without cluttering your route handlers or degrading performance.

back to articles