Guides
Guide

structlog: JSON Logging, Context, and Trace IDs in Python

By Sazabi Team

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 structlog

A 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_dict

Append 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_tracebacks as-is. The docs call it a shortcut for ExceptionRenderer with an ExceptionDictTransformer, and that transformer defaults to show_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 with show_locals=False, as in the first example.
  • Forgetting clear_contextvars(). Context outlives the request on long-lived workers.
  • Mixing render_to_log_kwargs with ProcessorFormatter. Pick one integration style.
  • Configuring late. get_logger() returns a lazy proxy that assembles the real logger on first use, and with cache_logger_on_first_use=True that assembled logger is kept. Call structlog.configure at 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 pass order_id as 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 selects ConsoleRenderer in 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 password and authorization.

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

  1. 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)
  2. ProcessorFormatter, wrap_for_formatter, foreign_pre_chain, and remove_processors_meta route structlog and standard library records through one renderer (accessed Nov 8, 2026)
  3. 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)
  4. 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)
  5. Log to standard out, emit structured output such as JSON in production, and use canonical log lines (accessed Nov 8, 2026)
  6. trace.get_current_span().get_span_context() returns the active span context (accessed Nov 8, 2026)