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 written for backend engineers and SREs who already have a FastAPI service running and now need its logs to be queryable and correlatable in production. It is part of the comparison in 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 — the mechanism explained in using contextvars for request tracing. That single property is why this page recommends structlog's processor pipeline as the default for FastAPI, with Loguru's sink-based configuration as a reasonable alternative when its built-in rotation and rich tracebacks outweigh flat-output and context-composition concerns.

One request, one context, one JSON stream Top row: an HTTP request carrying an x-request-id header enters middleware, which clears and binds contextvars; the route and its dependencies run next and every log.info call reads that context back; the renderer emits flat JSON. Middle row: a contextvars store holding request_id, method and path, written once by the middleware and read by the handler, holding one isolated copy per asyncio task. Bottom row: uvicorn and gunicorn standard library LogRecords pass through structlog's ProcessorFormatter, whose foreign_pre_chain merges the same context, and join the application's renderer output on a single stdout JSON stream that should be queued if the write can block. request path HTTP request x-request-id header middleware clear + bind_contextvars route + dependencies every log.info() call JSON renderer flat top-level keys bind read context store contextvars: one copy per asyncio task request_id, method, path — bound once same stream server + library logs uvicorn + gunicorn stdlib LogRecords ProcessorFormatter foreign_pre_chain merges ctx stdout — one JSON stream queue the sink if it can block
Context is bound once at the request boundary and read back by every call site; the server's own records join the same renderer, so one stream carries both.

Prerequisites

Install FastAPI, an ASGI server, and both candidate libraries with pinned ranges so the examples reproduce.

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

Python 3.10 or newer is assumed, mainly because contextvars propagation through asyncio.TaskGroup and third-party middleware is well settled there.

How each library scopes request context

Every other difference between the three candidates — output format, configuration style, traceback richness — is recoverable with a few lines of glue. Context scoping is not, so it should drive the decision.

The standard library has no concept of request scope at all. A LoggerAdapter carries fixed extras, and a custom Filter can inject fields into every record, but the value has to come from somewhere: if that somewhere is a threading.local, it is wrong under asyncio, because many tasks share one thread and therefore one thread-local. The correct standard-library pattern is a module-level ContextVar read by a filter, which works but leaves you owning the plumbing — the approach detailed in structured logging with the Python standard library and configuring logging with dictConfig.

structlog ships that plumbing as a first-class feature. structlog.contextvars.bind_contextvars writes into a contextvar-backed dict, and the merge_contextvars processor folds it into every event as flat keys. Because asyncio copies the context when a task is created, a value bound in middleware is visible to the handler, to its dependencies, and to any task those spawn, while remaining invisible to concurrently running requests. That composition — binding at several levels and merging once at render time — is covered in binding context variables in structlog.

Loguru offers logger.contextualize(**kwargs), a context manager that is also contextvar-backed, so it is genuinely async-safe. The difference is compositional rather than correctness-based: contextualized values land in the record's extra dict, which serializes as a nested record.extra object rather than flat top-level keys, and the context manager shape does not fit a middleware that binds incrementally as a request progresses. Loguru's real advantages lie elsewhere — file rotation and retention without extra handlers, and enqueue=True for non-blocking dispatch. In a containerized service that writes JSON to stdout, those advantages are largely neutralized by the platform, which is why the recommendation lands on structlog. If your service is a small worker writing files on a host, the trade runs the other way, as weighed in Loguru vs structlog for microservices.

How the three libraries scope request context under asyncio Two concurrent requests, A binding req-aaa and B binding req-bbb, run as interleaved tasks on one event loop. In the standard library lane the values go into a threading.local, which is one shared dict because both tasks run on the same thread, so B's bind overwrites A's and both emitted lines carry req-bbb — A's line is simply wrong. In the structlog lane bind_contextvars writes into a contextvar, so each task holds an isolated copy and merge_contextvars renders it as flat top-level keys, giving each line its own request id. In the Loguru lane contextualize is also contextvar-backed and equally isolated, but the values land inside the record's extra dict, so the line nests them under an extra object instead of flattening them. library two tasks, one loop where the context lives what each task emits stdlib threading.local request A binds req-aaa request B binds req-bbb one shared dict B's bind overwrites A's "request_id": "req-bbb" wrong — A's line shows B "request_id": "req-bbb" right, but only by luck structlog bind_contextvars request A binds req-aaa request B binds req-bbb isolated per task merged as flat keys "request_id": "req-aaa" flat, isolated "request_id": "req-bbb" flat, isolated Loguru contextualize() request A binds req-aaa request B binds req-bbb isolated per task kept in record.extra "extra": {"request_id": "req-aaa"} isolated, but nested "extra": {"request_id": "req-bbb"} isolated, but nested
Only the standard library lane is actually broken; the real choice between structlog and Loguru is the shape of the output — flat top-level keys versus a nested extra object.

Implementation

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

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. Setting a filtering bound logger here means sub-threshold calls are dropped before any processor runs, which matters on a hot request path; the level taxonomy behind that threshold is covered in log levels and severity mapping.

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
    )

2. Bind request-scoped context in middleware. A single HTTP middleware clears any prior context, binds a request id and route, and lets it fall out of scope on the way out. Because bind_contextvars writes to a contextvar, each concurrent request sees only its own values. Echoing the id back on the response header lets a client or gateway correlate a failure report with the exact log lines.

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}

Use the route template (request.scope.get("route")) rather than the raw path if you plan to aggregate by endpoint — /orders/ORD-1 and /orders/ORD-2 are different paths but the same route, and unbounded path values behave like a high-cardinality label. If the service is also traced, bind the trace and span ids in the same middleware so logs join to spans, the correlation pattern described in adding trace IDs to log records and produced automatically when setting up OpenTelemetry in FastAPI.

3. Unify uvicorn and gunicorn loggers. uvicorn and gunicorn log through the standard library, so without this step you get two formats in one stream and a broken parse in your collector. Route those foreign 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(...)

foreign_pre_chain is the part teams miss: it applies to records that did not originate in structlog, which is exactly what uvicorn access logs and third-party library output are. Including merge_contextvars there means a database driver's warning emitted during a request still carries that request's request_id.

4. Keep the sink off the event loop. With stdout as the sink, a write is a fast syscall and the loop barely notices. A rotating file or a network sink is a different matter: the write runs inline on the loop thread and its latency is added to every request currently awaiting. Wrap the handler in a QueueHandler/QueueListener pair, as in non-blocking logging with QueueHandler, so the loop only pays for an in-memory put.

import atexit
import logging
import queue
from logging.handlers import QueueHandler, QueueListener

def install_queue_sink(target: logging.Handler) -> None:
    log_queue: queue.Queue = queue.Queue(-1)   # unbounded; watch depth in prod
    root = logging.getLogger()
    root.handlers = [QueueHandler(log_queue)]  # loop thread only enqueues
    listener = QueueListener(log_queue, target, respect_handler_level=True)
    listener.start()                           # writes happen on this thread
    atexit.register(listener.stop)             # drain before the process exits

If you prefer Loguru instead, the equivalent of step 2 is logger.contextualize(request_id=...) wrapped around call_next, the equivalent of step 3 is an InterceptHandler on the root logger, and step 4 collapses into 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 and produces flat keys, whereas Loguru's strengths 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 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 serializes through one writer. 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
foreign_pre_chain ProcessorFormatter include merge_contextvars gives library logs the request context
log_config uvicorn.run None hands formatting to your renderer
enqueue (Loguru) logger.add True non-blocking, loop-safe sink
Which logging library a FastAPI service should use Starting from an async FastAPI service that needs per-request log context, three questions are asked in order. First, must you keep an existing dictConfig setup? If yes, use the standard library with a ContextVar filter: you own the plumbing but it is async-safe. If no, does the service write rotating files on a host rather than stdout? If yes, use Loguru for its built-in rotation, retention and enqueue, and note that contextualize is async-safe too. If no, does the log store need flat top-level keys? If yes, use structlog, the default recommendation here, with merge_contextvars and flat JSON. If no, Loguru is fine as well because a nested record.extra object is acceptable. FastAPI, async, needs per-request log context a dictConfig setup you must keep? yes stdlib logging + a ContextVar filter async-safe, but you own the plumbing no rotating files on a host rather than stdout? yes Loguru — rotation, retention, enqueue contextualize() is async-safe too no log store indexes flat top-level keys? yes structlog — the recommended default merge_contextvars, flat JSON to stdout no Loguru is a fair pick here too a nested record.extra costs you nothing
Three questions settle it: an inherited dictConfig mandate, a file sink on a host, and whether the log store wants flat keys.

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. Two lines are visibly interleaved between requests, which is what proves the test actually exercised concurrency rather than running serially.

What a passing isolation check looks like on the wire A wall-clock axis spanning four milliseconds inside the same second. At 09:20:11.001 request req-aaa emits request_started carrying request_id req-aaa. At 09:20:11.002 request req-bbb emits its own request_started carrying request_id req-bbb. At 09:20:11.003 req-aaa emits order_fetched, still carrying req-aaa. At 09:20:11.004 req-bbb emits order_fetched carrying req-bbb. The two requests alternate on the timeline, which proves the calls really overlapped, and no line ever carries the other request's id, which proves the context stayed isolated. two requests interleaved on one event loop req-aaa req-bbb request_started request_id: req-aaa request_started request_id: req-bbb order_fetched request_id: req-aaa order_fetched request_id: req-bbb wall clock 09:20:11.001 09:20:11.002 09:20:11.003 09:20:11.004
The alternation proves the requests really overlapped; the unchanged id on each track proves the context never crossed between them.

Make it a regression test so the property survives refactoring. FastAPI's TestClient is synchronous, so drive the app with an async client and assert on captured output:

import json
import httpx
import pytest
from main import app

@pytest.mark.asyncio
async def test_request_ids_do_not_leak(capsys):
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
        await c.get("/orders/ORD-1", headers={"x-request-id": "req-aaa"})
        await c.get("/orders/ORD-2", headers={"x-request-id": "req-bbb"})

    lines = [json.loads(l) for l in capsys.readouterr().out.splitlines() if l]
    for line in lines:                       # each line's ids must agree
        assert line["request_id"] == f"req-{'aaa' if 'ORD-1' in line['path'] else 'bbb'}"

Expected Output:

1 passed in 0.34s

A final sanity check is that an access log line from uvicorn is valid JSON with the same keys as your application lines. If curl -s localhost:8000/orders/ORD-1 | head -1 yields JSON but the server's own access line does not, step 3 has not taken effect.

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. The thread-local variant is subtle because it is correct in a synchronous WSGI app and only fails once tasks share a thread, as explained in context variables and thread safety.

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.

Error signature: context is present in the middleware but empty inside a background task. Root cause: the work was handed to a thread executor or a BackgroundTasks callback that ran after the middleware unwound and cleared the context. Remediation: capture the values you need before scheduling (structlog.contextvars.get_contextvars()), pass them explicitly, and re-bind them at the top of the background callable.

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.