Choosing a Logging Library for FastAPI

The exact problem this page solves: picking and wiring a logging library so a concurrent, async FastAPI service emits flat JSON with per-request context that never leaks between requests. It is part of structlog vs Loguru vs Standard Library Logging and the broader Modern Python Logging Libraries Deep Dive. FastAPI's async model raises the stakes for context isolation, so the right answer leans heavily on contextvars.

The decision matters more in FastAPI than in a synchronous WSGI app. Under asyncio, a single worker process interleaves many in-flight requests on one event loop, so any per-request state stored in a place that is shared across tasks, such as a module global or thread-local, will be observed by the wrong request. The libraries that scope cleanly here are the ones built on contextvars, because each task receives an isolated copy of the context at creation. That single property is why this guide recommends structlog as the default for FastAPI, with Loguru as a reasonable alternative when its built-in rotation and rich tracebacks outweigh flat-output and context-composition concerns.

FastAPI request-scoped logging flow An HTTP request enters middleware which binds request_id into contextvars; route handlers and dependencies read the same contextvars; the JSON renderer emits a flat line; middleware clears the context on response. HTTP request enters app middleware bind ctxvars handlers + deps log JSON line flat output contextvars (per-task copy) request_id shared, isolated
Request context is bound once in middleware and read by every logger through an isolated per-task contextvar copy.

Prerequisites

pip install "fastapi>=0.110,<1.0" "uvicorn>=0.29,<1.0" \
            "structlog>=24.1.0,<26.0.0" "loguru>=0.7.0,<0.8.0"

Useful environment variables for switching renderers without code changes:

export LOG_LEVEL=INFO       # filtering threshold
export LOG_RENDERER=json    # "json" in prod, "console" for local dev

Implementation

The recommended setup uses structlog because its contextvars integration aligns exactly with how asyncio scopes state per request. The walkthrough has three parts: configure once, bind in middleware, and unify the server loggers.

  1. Configure structlog at import time. Run configuration in a module imported before the app starts. Choose the final renderer from an environment variable so production gets JSON and local development gets colorized console output.
import logging
import os
import structlog

def configure_logging() -> None:
    level = getattr(logging, os.getenv("LOG_LEVEL", "INFO"))
    renderer = (
        structlog.processors.JSONRenderer()
        if os.getenv("LOG_RENDERER", "json") == "json"
        else structlog.dev.ConsoleRenderer()
    )
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,   # pull in request context
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,       # render exceptions safely
            renderer,
        ],
        wrapper_class=structlog.make_filtering_bound_logger(level),
        cache_logger_on_first_use=True,                 # avoid per-call rebuild
    )
  1. Bind request-scoped context in middleware. A single HTTP middleware clears any prior context, binds a request id and route, and unbinds on the way out. Because bind_contextvars writes to a contextvar, each concurrent request sees only its own values.
import uuid
import structlog
from fastapi import FastAPI, Request

configure_logging()
app = FastAPI()
log = structlog.get_logger("app")

@app.middleware("http")
async def bind_request_context(request: Request, call_next):
    structlog.contextvars.clear_contextvars()
    request_id = request.headers.get("x-request-id", str(uuid.uuid4()))
    structlog.contextvars.bind_contextvars(
        request_id=request_id,
        method=request.method,
        path=request.url.path,
    )
    log.info("request_started")
    response = await call_next(request)
    log.info("request_finished", status_code=response.status_code)
    response.headers["x-request-id"] = request_id
    return response

@app.get("/orders/{order_id}")
async def get_order(order_id: str):
    # No need to pass request_id; it is already in the context.
    log.info("order_fetched", order_id=order_id)
    return {"order_id": order_id}
  1. Unify uvicorn and gunicorn loggers. uvicorn and gunicorn log through the standard library. Route those records through the same renderer with structlog.stdlib.ProcessorFormatter, and start uvicorn with log_config=None so its default handlers do not double-format.
import logging
import structlog

def install_stdlib_bridge() -> None:
    formatter = structlog.stdlib.ProcessorFormatter(
        foreign_pre_chain=[
            structlog.contextvars.merge_contextvars,
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt="iso"),
        ],
        processors=[
            structlog.stdlib.ProcessorFormatter.remove_processors_meta,
            structlog.processors.JSONRenderer(),
        ],
    )
    handler = logging.StreamHandler()
    handler.setFormatter(formatter)
    root = logging.getLogger()
    root.handlers = [handler]
    root.setLevel(logging.INFO)
    # uvicorn loggers propagate to root once their own handlers are cleared.
    for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
        lg = logging.getLogger(name)
        lg.handlers = []
        lg.propagate = True

# Run with: uvicorn main:app --log-config=/dev/null
# or pass log_config=None when calling uvicorn.run(...)

If you prefer Loguru instead, the equivalent of step 2 is logger.contextualize(request_id=...) wrapped around call_next, and the equivalent of step 3 is an InterceptHandler on the root logger plus logger.add(sys.stdout, serialize=True, enqueue=True). The trade-off favoring structlog for async FastAPI is that merge_contextvars composes naturally with the per-task context model, whereas Loguru's strengths (rotation, rich tracebacks) matter less when logs ship to stdout in a container.

Two operational details often bite teams here. First, gunicorn-managed uvicorn workers each import the application module, so configure_logging() runs once per worker, which is correct; do not try to share a single configured logger across workers. Second, when you scale to multiple uvicorn workers, prefer logging to stdout and letting the platform aggregate, rather than each worker writing the same file, because concurrent file writes from separate processes interleave unpredictably unless every sink uses enqueue=True. With stdout JSON and a collector, the multi-worker case needs no special handling at all, which is another reason the recommended setup renders to a stream rather than a file.

Configuration options

Option Where Recommended value Effect
LOG_RENDERER env var json in prod flat JSON for log shippers
wrapper_class structlog.configure make_filtering_bound_logger(INFO) drops sub-level events before serialization
cache_logger_on_first_use structlog.configure True avoids rebuilding the chain per call
clear_contextvars middleware call first prevents stale context reuse
log_config uvicorn.run None hands formatting to your renderer
enqueue (Loguru) logger.add True non-blocking, loop-safe sink

Verification

Send two concurrent requests with distinct request ids and confirm the context does not bleed. Each request should produce its own request_id on every line.

curl -H "x-request-id: req-aaa" localhost:8000/orders/ORD-1 &
curl -H "x-request-id: req-bbb" localhost:8000/orders/ORD-2 &
wait

Expected Output:

{"request_id": "req-aaa", "method": "GET", "path": "/orders/ORD-1", "event": "request_started", "level": "info", "timestamp": "2026-06-19T09:20:11.001Z"}
{"request_id": "req-bbb", "method": "GET", "path": "/orders/ORD-2", "event": "request_started", "level": "info", "timestamp": "2026-06-19T09:20:11.002Z"}
{"request_id": "req-aaa", "method": "GET", "path": "/orders/ORD-1", "order_id": "ORD-1", "event": "order_fetched", "level": "info", "timestamp": "2026-06-19T09:20:11.003Z"}
{"request_id": "req-bbb", "method": "GET", "path": "/orders/ORD-2", "order_id": "ORD-2", "event": "order_fetched", "level": "info", "timestamp": "2026-06-19T09:20:11.004Z"}

Every line carries the correct, isolated request_id, which is the definitive sign that contextvars scoping is working under concurrency.

Common mistakes

Error signature: every log line shows the same request_id under load. Root cause: binding context to a module global or thread-local instead of a contextvar, or forgetting clear_contextvars at the start of the middleware. Remediation: call clear_contextvars() then bind_contextvars(...) at the top of an HTTP middleware so each task starts clean and isolated.

Error signature: uvicorn access logs print as plain text alongside your JSON. Root cause: uvicorn's default logging config is still active. Remediation: start uvicorn with log_config=None (or --log-config=/dev/null), clear the uvicorn loggers' handlers, and set propagate = True so they reach your root handler.

Error signature: latency spikes and event-loop stalls during bursts. Root cause: a synchronous file or network sink runs on the event loop. Remediation: wrap the stdlib handler in QueueHandler/QueueListener, or use Loguru with enqueue=True, so serialization and I/O move to a background worker.

Frequently Asked Questions

Should I use structlog or Loguru for FastAPI?

For most FastAPI services structlog is the better fit because its contextvars-based binding matches async request scoping cleanly and its flat JSON output indexes well. Loguru is a strong choice for smaller services that value its built-in rotation and rich tracebacks over flat output.

Why do my FastAPI log fields leak between concurrent requests?

Because the fields were stored in a thread-local or a module global rather than a contextvar. Use structlog.contextvars.bind_contextvars inside an HTTP middleware so each async request gets an isolated copy of the context.

How do I make uvicorn logs use the same JSON format?

Attach a structlog ProcessorFormatter to the root logger and disable uvicorn's default handlers by passing log_config=None, so uvicorn.access and uvicorn.error records flow through your renderer.

Does logging block the FastAPI event loop?

Synchronous file or network sinks can block the loop. Route output through a QueueHandler and QueueListener, or use Loguru with enqueue=True, so serialization and I/O run on a background worker.