• Python Home
  • Python Learning Path

FastAPI Middleware

What Is Middleware?

A middleware is a piece of code that runs on every request before it reaches path operation (route handler), and again on the response before it goes back to the client. Think of it as a wrapper around your entire application.

The flow looks like this:

Client → Middleware(s) → Path Operation → Middleware(s) → Client

Concretely, a middleware:

  1. Receives the incoming request.
  2. Can inspect or modify it, or run any code (logging, auth checks, timing, etc.).
  3. Passes the request down the chain (eventually to your route).
  4. Receives the response that comes back.
  5. Can inspect or modify the response.
  6. Returns the response.

FastAPI is built on Starlette, and Starlette implements the ASGI spec. This matters because it means:


Creating Your First Middleware

The simplest way to add middleware is the @app.middleware(“http”) decorator on top of a function.

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"] = str(process_time)
    return response

@app.get("/")
async def root():
    return {"message": "Hello World"}

Key points:

Custom headers should use the X- prefix by convention.


Multiple Middleware & Execution Order

You’ll almost always have more than one middleware in a real app (CORS, logging, auth, compression…). Order matters, and it’s easy to get backwards.

Whether you register middleware with @app.middleware(“http”) or app.add_middleware(…), each one wraps the app, forming a stack. The last one added is the outermost layer.

app.add_middleware(MiddlewareA)
app.add_middleware(MiddlewareB)

Execution order:

Mental model: picture middleware as layers of an onion. The request has to pass through every layer to reach the core (your route), and the response has to pass back through every layer to exit.

FastAPI middleware onion model showing how HTTP requests pass through middleware layers before reaching the route and responses return through the layers in reverse order.
FastAPI middleware visualized as an onion: requests pass inward through each middleware layer to reach the route, while responses travel outward through the same layers in reverse order.

Practical rule of thumb: register middleware that needs to see the raw, unmodified request first (like TrustedHostMiddleware or a security-headers middleware) as one of the outer layers, and register things that depend on the final response body last (like GZip compression, which should usually be one of the outermost layers so it compresses the final output).


RequestContext Middleware

Context

In a real FastAPI app, HTTP request flows through

Middleware -> Router -> Service -> DB OR External API

During that flow you usually need the same 2-3 pieces/more of data everywhere:

Passing them as args to every function gets messy fast.

The Problem

Without a context system you end up with 2 issues:

    def get_user(user_id, request_id, tenant_id, db):

      Example of the bad way:

      def service_a(user_id, request_id):
         logger.info(f"{request_id} calling service_b")
         service_b(user_id, request_id) # Have to keep passing it

      The Solution Approach

      Use contextvars + Middleware to create “request-local storage”.

      When request comes in

      This is exactly how request object works in Flask, or ThreadLocal in Java.

      Implementation

      Step 1: Define Context Variables

      # core/context.py
      
      request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
      
      user_id_ctx: contextvars.ContextVar[str | None ] = contextvars.ContextVar("user_id", default=None)

      Step 2: Create Middleware to Set/Reset Context

      # middleware/request_context.py
      
      import uuid
      from starlette.middleware.base import BaseHTTPMiddleware
      from core.context import request_id_ctx, user_id_ctx
      
      class RequestContextMiddleware(BaseHTTPMiddleware):
          async def dispatch(self, request, call_next):
              request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
              user_id = getattr(request.state, "user_id", None) # set by auth middleware
      
              req_token = request_id_ctx.set(request_id)
              user_token = user_id_ctx.set(user_id)
      
              try:
                  response = await call_next(request)
                  response.headers["X-Request-ID"] = request_id # echo back for tracing
                  return response
              finally: # critical: reset to avoid leak
                  request_id_ctx.reset(req_token)
                  user_id_ctx.reset(user_token)

      Step 3: Register Middleware

      # main.py
      
      from fastapi import FastAPI
      from middleware.request_context import RequestContextMiddleware
      
      app = FastAPI()
      app.add_middleware(RequestContextMiddleware)

      Step 4: Use it Anywhere

      # services/order_service.py
      
      import logging
      from core.context import request_id_ctx, user_id_ctx
      
      logger = logging.getLogger(__name__)
      
      def create_order():
          rid = request_id_ctx.get()
          uid = user_id_ctx.get()
          logger.info(f"[{rid}] User {uid} creating order") # logs are now traceable
      
          # call other service, db, etc. Still have access
          return {"status": "ok", "request_id": rid}

      Step 5: Add to Logs

      So you don’t have to manually add rid/request_id every time:

      # logging.py
      
      import logging
      from core.context import request_id_ctx
      
      class ContextFilter(logging.Filter):
          def filter(self, record):
              record.request_id = request_id_ctx.get()
              return True
      
      logging.basicConfig(format="%(asctime)s [%(request_id)s] %(message)s")
      logging.getLogger().addFilter(ContextFilter())

      Summary

      ProblemSolution with RequestContextMiddleware
      Pass data through 10 layerscontextvar.get() anywhere
      Can’t trace logsEvery log has request_id
      Leaking data between requestsreset() in finally block

      Step 6 (Optional) – Auto-propagate X-Request-ID to every Outbound

      Goal: Every inbound request gets/generates X-Request-ID and every outbound httpx call automatically forwards it. Devs should not manually pass headers.

      # clients/http.py
      
      import httpx
      from core.context import request_id_ctx
      
      class RequestIDHook:
          """httpx Event Hook - runs before every request"""
          async def on_request(self, request: httpx.Request):
              req_id = request_id_ctx.get()
              if req_id:
                  request.headers["X-Request-ID"] = req_id
      
      # Singleton client for the whole app
      client = httpx.AsyncClient(
          event_hooks={"request": [RequestIDHook().on_request]},
          timeout=httpx.Timeout(10.0)
      )

      now in main.py python file add snippet of code,

      # main.py
      
      from clients.http import client
      
      @app.get("/users/{user_id}")
      async def get_user(user_id: str):
          # This call will auto-have X-Request-ID
          r = await client.get(f"https://api.github.com/users/{user_id}")
          return r.json()
      
      @app.get("/debug")
      async def debug():
          print("Current ID:", get_request_id())
          await client.get("https://httpbin.org/headers") # check response to see header sent
          return {"id": get_request_id()}

      Best Practices