FastAPI request_id logging (correlation ID middleware, concurrency-safe)

Every HTTP request needs a unique identifier for tracing. Without proper request ID propagation, debugging distributed systems becomes nearly impossible—you can’t correlate logs from a single user action across services.

The Problem: Overlapping Request IDs

A common approach is using threading.local() to store the request ID:

import threading
import uuid
from fastapi import FastAPI, Request

# DON'T DO THIS - breaks with async
_local = threading.local()

app = FastAPI()

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    _local.request_id = str(uuid.uuid4())  # Set ID
    response = await call_next(request)
    return response

def get_request_id():
    return getattr(_local, "request_id", None)

This breaks under concurrency. When you await in async code, Python can switch to another coroutine running on the same thread. That coroutine might overwrite _local.request_id, and when your original request resumes, it sees the wrong ID.

Symptoms:

  • Request IDs appear in logs for the wrong requests

  • The same request_id shows up in multiple unrelated requests

  • Debug sessions become impossible because logs don’t correlate

This is a frequently asked question on Stack Overflow and catches many developers off guard.

The Solution

fapilog uses Python’s contextvars module, which correctly isolates context per async task:

from fastapi import FastAPI, Depends
from fapilog.fastapi import FastAPIBuilder, get_request_logger

app = FastAPI(
    lifespan=FastAPIBuilder()
        .with_preset("fastapi")
        .build()
)

@app.get("/")
async def root(logger=Depends(get_request_logger)):
    logger.info("request_id is automatically included")
    return {"status": "ok"}

That’s it. Every log entry automatically includes the request_id, and it never leaks between concurrent requests.

Output:

{"timestamp": "2026-01-21T10:30:00.123Z", "level": "INFO", "message": "request_id is automatically included", "request_id": "550e8400-e29b-41d4-a716-446655440000"}

Accessing request_id in Deeper Layers

The request ID is available anywhere in your async call stack without passing it explicitly:

from fapilog.core.errors import request_id_var

async def my_service_function():
    """Called deep in the application - no logger passed in."""
    current_request_id = request_id_var.get(None)
    # Use for external API calls, database queries, etc.
    return current_request_id

For logging in service layers, get a logger directly:

from fapilog import get_async_logger

async def process_order(order_id: str):
    """Service layer - request_id flows automatically."""
    logger = await get_async_logger("orders")
    await logger.info("Processing order", order_id=order_id)
    # request_id is automatically included via ContextVarsEnricher

Passing Context to Sync Code

If you have synchronous code that runs within an async request (e.g., a sync database driver), the context variable is still accessible:

from fapilog.core.errors import request_id_var

def sync_database_call(query: str):
    """Sync function called from async context."""
    request_id = request_id_var.get(None)
    # request_id is available because contextvars work across sync/async boundaries
    execute_query(query, correlation_id=request_id)

Why This Works (Technical Detail)

Python’s contextvars module (PEP 567) provides task-local storage that correctly handles async context switches:

  1. Per-task isolation: Each asyncio Task gets its own copy of context variables

  2. Automatic propagation: When you await, the context follows your execution

  3. No thread confusion: Unlike threading.local(), context doesn’t leak between concurrent tasks on the same thread

fapilog’s RequestContextMiddleware sets request_id_var at the start of each request:

# Simplified view of what happens internally
from contextvars import ContextVar

request_id_var: ContextVar[str] = ContextVar("request_id")

# In middleware:
token = request_id_var.set(str(uuid.uuid4()))
try:
    response = await call_next(request)
finally:
    request_id_var.reset(token)  # Clean up

The ContextVarsEnricher (enabled by default) reads this value and adds it to every log entry.

Going Deeper