What structured logging with structlog is and why FastAPI services need it
structlog is often described as a nicer logger, but that undersells it. A better description is a pipeline: you bind key-value context (a request ID, a user ID, a route) once, and every log call made while handling that request picks it up automatically. The output is a structured event, not a string someone has to parse later.
That distinction matters once logs leave your terminal. A line like "GET /orders/42 returned 500 in 812ms" is readable, but a log aggregator can only search it as text. To find every slow 500 on one route, you write a regex and hope the message format never changed. With key-value JSON, status, path and duration_ms are fields. You can filter on them exactly, compute percentiles, group by route, and build alerts on thresholds instead of string matches. When a format change breaks a regex, alerts fail silently. JSON fields avoid that failure.
Backend engineers benefit when debugging: one request_id pulls every line for a failing request out of thousands of concurrent ones. Platform engineers benefit because consistent field names across services make dashboards and alerts reusable.
Anatomy of a per-request JSON line
A useful access log line carries these fields:
- timestamp: ISO 8601, in UTC
- level:
info,warning,error - event: a short, stable name such as
request_completed - logger: the module that emitted it
- request_id: the correlation key
- method and path: what was called
- status: the response code
- duration_ms: the latency, as a number rather than a string
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars
# Each processor enriches the event dict in turn; the renderer runs last.
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars, # pull in request-scoped context
structlog.processors.add_log_level, # adds "level"
structlog.processors.TimeStamper(fmt="iso", utc=True), # adds "timestamp"
structlog.processors.JSONRenderer(), # one JSON object per line
]
)
# Bind the emitting module once; it travels with every call on this logger.
log = structlog.get_logger(logger="app.access")
def handle_request() -> None:
# Start each request with a clean slate so context never leaks between requests.
clear_contextvars()
# Bind once; every log call during this request inherits these keys.
bind_contextvars(
request_id="7f3c9a1e-placeholder",
method="GET",
path="/orders/42",
)
# Event is a stable name; the variable parts are typed fields, not prose.
log.info("request_completed", status=500, duration_ms=812.4)
handle_request()
# Output is one line (wrapped here; key order may differ):
# {"logger": "app.access", "status": 500, "duration_ms": 812.4,
# "event": "request_completed", "request_id": "7f3c9a1e-placeholder",
# "method": "GET", "path": "/orders/42", "level": "info",
# "timestamp": "2026-10-22T09:14:03.512345Z"}
A minimal structlog pipeline that binds request context once and renders a single JSON access line with timestamp, level, event, logger, request_id, method, path, status and a numeric duration_ms.
How the pipeline works: contextvars, processors and one formatter
Every record in that schema is produced by the same path, whether the call came from your code, a third-party library, or uvicorn itself.
The flow of one log record
- Middleware binds context. When a request arrives, middleware calls
bind_contextvars(request_id=...). The value is stored in a context variable rather than passed through function arguments. - Application code logs. A structlog logger call builds an event dict containing the message and any keyword arguments.
- Foreign records join. Records from the standard library
loggingmodule, including uvicorn's, reach a handler whose formatter is structlog'sProcessorFormatter. Itsforeign_pre_chainturns eachLogRecordinto an event dict. - Shared processors enrich. Both sources run through the same chain.
merge_contextvarscopiesrequest_idinto the event.add_log_levelandTimeStamperadd the level and timestamp.StackInfoRendererand an exception processor such asformat_exc_infoturn stack and traceback data into fields. - One renderer emits. A single
JSONRendererserializes the result. Because the renderer is defined once, every line has the same shape regardless of where it came from.
Why contextvars and not thread-locals
Under asyncio, many requests run on one event-loop thread and interleave at every await. A thread-local value would be shared by all of them, so request A could log with request B's ID after a context switch. Context variables are scoped to the asyncio task instead. Each request is handled in its own task, so the bound request_id stays with that request across awaits, and other requests running concurrently on the same thread cannot see it.
HTTP request
v
+---------------------------+
| Middleware |
| bind_contextvars( |
| request_id=...) |
+-------------+-------------+
v request_id held in a contextvar (per task)
+---------------------------+ +-------------------------------+
| App code (structlog) | | stdlib logging / uvicorn |
| log.info("event", k=v) | | logging.LogRecord |
+-------------+-------------+ +---------------+---------------+
| | ProcessorFormatter
| | foreign_pre_chain
v v
+-----------------------------------------------+
| Shared processor chain |
| merge_contextvars -> adds request_id |
| add_log_level |
| TimeStamper |
| StackInfoRenderer |
| format_exc_info |
+-----------------------+-----------------------+
v
+-------------------------+
| JSONRenderer (only one) |
+------------+------------+
v
one JSON line, same shape for every source
Structlog calls from application code and stdlib or uvicorn records, converted to event dicts by ProcessorFormatter's foreign_pre_chain, pass through one shared processor chain that merges the request_id from contextvars, and are then serialized by a single JSONRenderer.
Configuring structlog and stdlib logging together
event loop never see it.
The setup module
Put configuration in one function, such as configure_logging(), and call it once before the app starts. It defines a shared_processors list: structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.stdlib.add_logger_name, and structlog.processors.TimeStamper(fmt="iso"). Both structlog calls and plain logging records from libraries pass through this list, so every line has the same keys.
structlog.configure uses shared_processors plus a final structlog.stdlib.ProcessorFormatter.wrap_for_formatter. It sets logger_factory=structlog.stdlib.LoggerFactory() and wrapper_class=structlog.stdlib.BoundLogger. The structlog chain has no renderer. Rendering happens in one place: a ProcessorFormatter on a single root StreamHandler. Its foreign_pre_chain is shared_processors, and it ends with JSONRenderer() when LOG_FORMAT=json and ConsoleRenderer() otherwise. Make JSON the default so a missing variable in production still produces parseable output.
For uvicorn, uvicorn.error and uvicorn.access, clear handlers and set propagate = True so their records reach the root handler. If section 4's middleware writes access logs, also disable uvicorn.access. Start uvicorn with log_config=None so its default config does not replace yours.
| Symptom | Cause | Fix |
|---|---|---|
JSON string nested inside a JSON event field |
Renderer in both structlog.configure and the formatter |
Render only in ProcessorFormatter |
| Every uvicorn line appears twice | uvicorn handler plus propagation to root | Clear uvicorn handlers |
| Access lines in two formats | uvicorn access log plus middleware log | Disable uvicorn.access |
import logging
import os
import structlog
def configure_logging() -> None:
# Run on structlog events and on plain logging records, so both have the same keys.
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
]
structlog.configure(
# No renderer here. wrap_for_formatter passes the event dict to ProcessorFormatter.
processors=shared_processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# JSON is the default, so production output stays parseable if LOG_FORMAT is unset.
use_json = os.getenv("LOG_FORMAT", "json") == "json"
renderer = structlog.processors.JSONRenderer() if use_json else structlog.dev.ConsoleRenderer()
handler = logging.StreamHandler()
handler.setFormatter(structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared_processors, # applied to records from stdlib loggers
processors=[structlog.stdlib.ProcessorFormatter.remove_processors_meta, renderer],
))
root = logging.getLogger()
root.handlers = [handler] # the only handler that renders output
root.setLevel(logging.INFO)
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
uv_logger = logging.getLogger(name)
uv_logger.handlers.clear() # without this, uvicorn lines are written twice
uv_logger.propagate = True
# Access lines come from the request middleware, so turn off uvicorn's access log.
logging.getLogger("uvicorn.access").disabled = True
# Startup: configure_logging(); uvicorn.run(app, host="0.0.0.0", port=8000, log_config=None)
A configure_logging() function that renders output in a single ProcessorFormatter on the root handler, chooses JSON or console output from LOG_FORMAT, and sends uvicorn records to the root handler so no line is written twice or rendered twice.
Implementing request ID middleware and per-request access logs
The middleware's first job is structlog.contextvars.clear_contextvars(). Context variables are scoped to the task handling the request, but clearing them explicitly at the start ensures a reused worker task never carries a previous request's keys into the next one.
Next, read the incoming X-Request-ID header and decide whether to trust it. Accept it only if it is short (for example, at most 64 characters) and limited to a conservative character set such as letters, digits, hyphens, and underscores. Without that check, a client can inject newlines, oversized blobs, or values that break log parsers and dashboards.
- If the header is valid, use it as
request_id. - If it is missing or invalid, generate a UUID4.
- If you rejected a value, also bind a truncated copy as
upstream_request_id. This keeps traces correlatable with the proxy or caller.
Bind the result with bind_contextvars(request_id=...) before calling the next handler, so every log line emitted during the request inherits it.
Timing and the access log line
Record time.perf_counter() before dispatch. Emit the access line in a finally block containing:
- method
- path
- status code
duration_ms
If the handler raised, log status 500 so failed requests still produce a line. Set the same ID on the response header so clients can quote it in bug reports.
Keeping or disabling uvicorn's access log
Uvicorn's access log knows nothing about your context, so its lines lack request_id. Running it alongside the middleware log produces the duplicate-format problem described above. Most teams start uvicorn with --no-access-log and rely on the middleware line instead.
Keep uvicorn's log only if you need records of requests rejected before they reach your application, such as malformed HTTP or requests refused during startup.
import re
import time
import uuid
import structlog
from fastapi import FastAPI, Request
app = FastAPI()
log = structlog.get_logger("access")
# fullmatch (not match + $) so a trailing newline is also rejected
VALID_ID = re.compile(r"[A-Za-z0-9_-]{1,64}")
@app.middleware("http")
async def request_context(request: Request, call_next):
# Drop anything left over from a previous request on this task
structlog.contextvars.clear_contextvars()
incoming = request.headers.get("x-request-id")
if incoming and VALID_ID.fullmatch(incoming):
request_id = incoming
else:
request_id = str(uuid.uuid4())
if incoming:
# Keep a bounded copy for correlation; the JSON renderer escapes it
structlog.contextvars.bind_contextvars(upstream_request_id=incoming[:64])
structlog.contextvars.bind_contextvars(request_id=request_id)
start = time.perf_counter()
status = 500 # reported if the handler raises
try:
response = await call_next(request)
status = response.status_code
response.headers["X-Request-ID"] = request_id
return response
finally:
log.info("request", method=request.method, path=request.url.path,
status=status, duration_ms=round((time.perf_counter() - start) * 1000, 2))
# Run with: uvicorn app:app --no-access-log (this line replaces uvicorn's access log)
A FastAPI HTTP middleware that clears structlog context, validates or generates a request ID, keeps a rejected upstream ID for correlation, emits one timed access log line even on exceptions, and echoes the ID in the response header.
Adding context deeper in the stack and logging exceptions once
After authentication succeeds, call bind_contextvars(user_id=..., tenant_id=...) from the dependency that resolves the user. Every later log line in that request then carries those fields through merge_contextvars, so you never pass identifiers through function signatures. For context that matters only within one function or loop, use log = logger.bind(order_id=order.id). It returns a new logger and leaves the shared context unchanged, so a value bound during one iteration cannot appear on unrelated lines.
Inside a handler, call logger.exception(...) only when you catch an error and handle it there. If you re-raise, do not log, because whoever catches the error next will log it. Unhandled errors need exactly one owner. That can be either the request middleware or an Exception handler, not both. The owner logs once with the traceback while the request's contextvars are still bound, and it returns the 500 response itself. If the error propagates out of the app instead, uvicorn logs it a second time without your context.
Context propagation depends on how the work is scheduled:
- Sync endpoints and dependencies that FastAPI runs in its threadpool keep the context.
asyncio.create_taskcopies the context when the task is created.- A raw
ThreadPoolExecutor.submitorrun_in_executordoes not copy it. Wrap the call withcontextvars.copy_context().run. BackgroundTasksrun after the response is sent. Whether they still see the request's fields depends on where your middleware clears them. Clearing at the start of each request is more forgiving than clearing in afinallyblock. The more robust option is to passrequest_idexplicitly and rebind it at the start of the task.- Queue workers run in a different process. Put
request_idin the job payload and bind it when the worker picks up the job.
Testing the logging setup with pytest
Logging is easy to break without noticing. Someone reorders processors or renames a field, and nothing fails until you need the logs during an incident. A few targeted tests catch those regressions cheaply.
structlog.testing.capture_logs is a context manager that collects event dicts in a list instead of rendering them. It works by temporarily replacing the configured processor chain. That means merge_contextvars does not run, so a plain capture_logs() block will not show request_id even when the middleware works correctly. To test context propagation, write a fixture that configures structlog with merge_contextvars followed by a structlog.testing.LogCapture() instance. Then read .entries from that instance. If your production config sets cache_logger_on_first_use=True, call structlog.reset_defaults() in the fixture teardown. Otherwise loggers cached in one test keep their configuration in later tests.
Use FastAPI's TestClient and send a request with a valid inbound ID. Assert three things:
- Every entry produced during the request has that ID.
- The response header has the same value.
- No entry logged after the request has it, which shows the context was cleared.
Then send a malformed ID, such as an oversized string or one with newlines. Assert that the header and the log entries both carry a newly generated ID that differs from the input.
These tests do not check the JSON renderer or the stdlib bridge. Test those separately. Run your real configuration and use pytest's capsys (or capfd, if the handler writes to the file descriptor directly) to read the output. Parse each line with json.loads and assert the required keys are present. Also emit a record through a standard logging.getLogger logger and confirm it comes out as the same JSON shape.
When to avoid it, alternatives, and impact on cost and engineering time
For a cron script or single-file internal tool, this setup is overkill. With no concurrent requests to correlate, logging.basicConfig and a readable format are faster to write and easier to read.
Failure modes to plan for
- Rejected requests leave no log line. If the server rejects malformed HTTP, or middleware that runs before yours rejects the request, your middleware never runs and no access log line is written. Keep the server's own error log enabled as a backstop.
- JSON is hard to read in a terminal. Use the console renderer when stdout is a TTY. Run CI and staging with the production JSON renderer so that key-name drift and serialization errors show up before production.
- Call-site inspection adds overhead. Adding file, function and line costs work on every event. Profile your hot paths before enabling it globally.
- Rich context increases volume and can leak data. A bound user object or request headers can carry emails and tokens. Allowlist the keys you log, or add a redaction processor.
Alternatives
| Option | Fits when | Trade-off |
|---|---|---|
| stdlib logging + JSON formatter | Few log calls, little per-request context | Context passed manually via extra |
| Other structured logging libraries | Team already standardised on one | Different context and stdlib integration models |
| OpenTelemetry correlation | Requests cross several services | More infrastructure; trace IDs replace a homegrown request ID |
Cost versus time saved
The work is front-loaded into initial setup and debugging the configuration. The payoff comes during incidents, when you can filter on one request ID instead of matching log lines by timestamp.
Illustrative estimate (assumed numbers, not a benchmark):
- Break-even months = setup hours ÷ (hours saved per incident × incidents per month)
- Assume 12 setup hours, 1 hour saved per incident and 3 incidents a month.
- 12 ÷ (1 × 3) = 4 months.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to structlog fastapi, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.