structlog: JSON Logging, Context, and Trace IDs in Python
Last verified on Nov 8, 2026.
structlog is a Python library that logs events as key-value pairs and renders them as JSON in production. You write log.info("order_placed", order_id=4812), structlog merges in any bound context, runs the event through a chain of processors, and prints one JSON line. This guide covers a working configuration, request context with contextvars, standard-library integration, and trace IDs.
Install and Log Your First Event
pip install structlogA minimal production configuration that writes JSON to stdout:
import logging
import structlog
from structlog.tracebacks import ExceptionDictTransformer
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.ExceptionRenderer(ExceptionDictTransformer(show_locals=False)),
structlog.processors.EventRenamer("message"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
log = structlog.get_logger()
log.info("order_placed", order_id=4812){ "order_id": 4812, "level": "info", "timestamp": "2026-11-08T06:46:45.360895Z", "message": "order_placed" }Each processor is a plain function that takes the logger, the method name, and the event dictionary, and returns the dictionary for the next processor. The last one renders it. That chain is the whole model: adding a field, dropping a key, or redacting a value is one small function.
Two settings are worth a note. make_filtering_bound_logger(logging.INFO) drops anything below INFO before any processor runs, which is the cheapest way to implement log levels. EventRenamer("message") changes the event key so the field name matches what most pipelines expect.
Bind Context Once, Log It Everywhere
structlog builds context incrementally. The contextvars module underneath keeps that context local to the current thread or async task, so concurrent requests do not mix. The recipe from the structlog docs: put merge_contextvars first in the chain, call clear_contextvars() at the start of each request, then bind values.
import uuid
import structlog
from flask import Flask, request
app = Flask(__name__)
@app.before_request
def bind_request_context():
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
request_id=request.headers.get("X-Request-ID", str(uuid.uuid4())),
method=request.method,
path=request.path,
)
@app.after_request
def log_response(response):
structlog.get_logger().info("request_finished", status=response.status_code)
return response{
"status": 200,
"path": "/hello",
"method": "GET",
"request_id": "abc-123",
"level": "info",
"timestamp": "2026-11-08T06:46:52.308946Z"
}Any module that calls structlog.get_logger() during that request now includes request_id, method, and path without being handed them. That is a correlation ID with no plumbing. The same two calls work in Django middleware or an ASGI app. Skip clear_contextvars() and context bound earlier on the same worker can carry into the next request.
structlog's best-practices page suggests going one step further: bind data as the request progresses and emit a single canonical log line at the end, instead of a dozen scattered ones.
Route Standard Library Logs Through structlog
Most applications also have libraries that use logging. You want their output in the same JSON. structlog's ProcessorFormatter does that: it is a logging.Formatter that runs both structlog events and plain logging records through your renderer.
import logging
import sys
import structlog
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
]
structlog.configure(
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,
)
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared_processors,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(),
],
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root = logging.getLogger()
root.addHandler(handler)
root.setLevel(logging.INFO)foreign_pre_chain runs only on records that did not come from structlog, so give it the same processors you configured for structlog. remove_processors_meta strips two internal keys. The docs warn that with ProcessorFormatter the structlog chain must end with wrap_for_formatter, not render_to_log_kwargs, or you get confusing errors. For the standard-library side of this setup, see the Python logging guide, and for Django specifically the Django logging guide.
Add Trace and Span IDs
structlog has no built-in OpenTelemetry link, but a processor is five lines. Read the active span context and add its IDs in the W3C hex format:
from opentelemetry import trace
def add_trace_ids(logger, method_name, event_dict):
ctx = trace.get_current_span().get_span_context()
if ctx.is_valid:
event_dict["trace_id"] = format(ctx.trace_id, "032x")
event_dict["span_id"] = format(ctx.span_id, "016x")
return event_dictAppend add_trace_ids to shared_processors. Inside a span, both structlog events and standard-library records get the IDs:
{
"event": "retrying connection",
"request_id": "req-8f3a",
"logger": "urllib3",
"level": "warning",
"timestamp": "2026-11-08T06:46:29.566553Z",
"trace_id": "9047a7773aacfab43fcc8fa6583683cc",
"span_id": "1cc5249f6bb7b8e4"
}That line is a urllib3 record, and it still carries the trace ID because it passed through foreign_pre_chain. Setup for the tracer itself is in the OpenTelemetry Python guide.
Common Mistakes
- Using
dict_tracebacksas-is. The docs call it a shortcut forExceptionRendererwith anExceptionDictTransformer, and that transformer defaults toshow_locals=True. In testing, the JSON for one exception included the local variables of each frame. That is a secrets leak waiting to happen. Build the renderer yourself withshow_locals=False, as in the first example. - Forgetting
clear_contextvars(). Context outlives the request on long-lived workers. - Mixing
render_to_log_kwargswithProcessorFormatter. Pick one integration style. - Configuring late.
get_logger()returns a lazy proxy that assembles the real logger on first use, and withcache_logger_on_first_use=Truethat assembled logger is kept. Callstructlog.configureat startup, before anything logs. - Interpolating values into the event string.
log.info(f"order {order_id} placed")creates a unique message per order. Keep the event name constant and passorder_idas a field, so the same event is easy to count and search.
Production Tips
- Write to stdout and let your platform or a collector forward it. The structlog docs recommend this over doing more work inside the application.
- Choose between pretty and JSON output by environment. The docs show a
sys.stderr.isatty()check that selectsConsoleRendererin a terminal and JSON elsewhere. - Keep field names stable across services. The JSON logging guide and logging best practices cover naming.
- Treat the processor chain as the place for redaction: one function that drops or masks keys such as
passwordandauthorization.
Sending structlog Output to Sazabi
Sazabi, an AI-native observability and security platform, ingests logs over OTLP. Its OpenTelemetry source docs show how to export Python logging records with an OpenTelemetry LoggingHandler and OTEL_EXPORTER_OTLP_ENDPOINT. Because the ProcessorFormatter setup above sits on the standard library, that handler can be attached to the same root logger. See the OpenTelemetry integration for the other ways to connect, and client errors for what to watch once request logs arrive.
Frequently Asked Questions
What is structlog used for?
structlog adds structured key-value logging to Python. You bind fields to a logger, pass them through a chain of processors, and render the result as JSON or colored console text.
Should I use structlog or the standard logging module?
Use structlog when you want bound context and field-first calls such as log.info("order_placed", order_id=4812). It can sit on top of logging, so libraries that use logging keep working.
How do I add a request ID to every structlog line?
Add structlog.contextvars.merge_contextvars as the first processor, call clear_contextvars and bind_contextvars(request_id=...) at the start of each request, and every log call in that request includes it.
Why does structlog show local variables in my JSON tracebacks?
The dict_tracebacks processor uses ExceptionDictTransformer, which defaults to show_locals=True. Build the processor yourself with show_locals=False to keep variable values out of your logs.
Sources
- structlog builds context incrementally, processors are functions that receive and return the event dictionary, and it can wrap the standard library logging module (accessed Nov 8, 2026)
- ProcessorFormatter, wrap_for_formatter, foreign_pre_chain, and remove_processors_meta route structlog and standard library records through one renderer (accessed Nov 8, 2026)
- merge_contextvars as a processor, bind_contextvars, and clear_contextvars at the start of a request handler; the context is safe in threaded and async code (accessed Nov 8, 2026)
- dict_tracebacks is a shortcut for ExceptionRenderer with ExceptionDictTransformer, whose show_locals parameter defaults to True; make_filtering_bound_logger and EventRenamer (accessed Nov 8, 2026)
- Log to standard out, emit structured output such as JSON in production, and use canonical log lines (accessed Nov 8, 2026)
- trace.get_current_span().get_span_context() returns the active span context (accessed Nov 8, 2026)