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.
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.
- 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
)
- 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_contextvarswrites 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}
- 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 withlog_config=Noneso 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.