mrkeyoor.com_
Thu 06 Aug 07:03 UTC
PyPIInfraupdated 06 Aug 2026

structlog

structlog turns Python logging into key-value data instead of formatted sentences. You call log.info("order_paid", order_id=42, cents=1999) and structlog passes that dict through a list of processor functions you configure: one adds a timestamp, one adds the level, one renders the result as JSON for production or as colored console output for development. Loggers are bindable, so you attach context once (log = log.bind(user_id=7)) and every later call carries it. It can either write output itself or forward everything into the standard library logging module.

Verdict

The right choice when your logs are data that something else queries, and the API has been stable enough to be worth learning once. If your app is small or your logs are mostly for a human reading a terminal, loguru will get you there faster.

API stability5/5CalVer releases with a written backward-compatibility policy; 26.1.0 added features and deprecated better-exceptions support with a year of notice, and the only removal was end-of-life Python 3.8 and 3.9.
Docs5/5structlog.org has a Why chapter, a getting-started tutorial, recipes, a performance chapter, and a full API reference, plus the standard-library integration gets its own long guide because it needs one.
Maintenance4/5Pushed August 2026 with only 29 open issues (35 counting PRs) on 4.9k stars and a CII best-practices badge, but it is essentially one maintainer funded by sponsors and Tidelift.
Ecosystem4/5First-class stdlib and Rich integration and a third-party extensions wiki, but framework glue for Django, FastAPI, or Celery is community-maintained rather than shipped.

Use it if

  • Your logs go into something that queries fields (Loki, Datadog, CloudWatch Insights) and you are tired of regex-parsing your own log lines
  • You want request-scoped context stamped on every line without threading a logger object through every function, which contextvars binding handles for asyncio and threads alike
  • You want the same call sites to produce readable colored output locally and machine-readable JSON in production by changing one config block
  • You need to assert on log output in tests, which capture_logs() makes a plain list of dicts instead of a string-matching exercise
Skip it if

Setup reality

pip install structlog has no runtime dependencies beyond typing-extensions on older Pythons, and it logs usefully with zero configuration. The work starts at the configure() call, where processor order is load-bearing: context merging first, then enrichers like TimeStamper and add_log_level, then exception handling, and the renderer strictly last, since anything after a renderer receives a string rather than a dict. configure() is process-global, and with cache_logger_on_first_use=True a logger created before configuration keeps the old pipeline, which is the classic reason your JSON config appears to do nothing. Standard-library interop is a separate chapter of setup, not a flag.

Patterns

Log key-value events with no configurationquick-start

import structlog

log = structlog.get_logger()
log.info("order_paid", order_id=42, cents=1999)
log.warning("retrying", attempt=2, backoff_s=1.5)

Out of the box you get colored console output. The event name is the first positional argument by convention; everything else should be keywords.

Production configuration that emits JSONconfigure-json

import logging, structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.StackInfoRenderer(),
        structlog.dev.set_exc_info,
        structlog.processors.dict_tracebacks,
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    logger_factory=structlog.WriteLoggerFactory(),
    cache_logger_on_first_use=True,
)

The renderer must be the last processor. cache_logger_on_first_use is the speed win, and also the reason configure() called after your first log line does nothing.

Switch renderer by environmentdev-vs-prod-renderer

import os, structlog

shared = [
    structlog.contextvars.merge_contextvars,
    structlog.processors.add_log_level,
    structlog.processors.TimeStamper(fmt="iso"),
]
renderer = (
    structlog.processors.JSONRenderer()
    if os.getenv("ENV") == "production"
    else structlog.dev.ConsoleRenderer()
)
structlog.configure(processors=[*shared, renderer])

Keep the shared list identical across environments; if dev and prod differ in enrichers you will only find missing fields in production.

Bind values onto a loggerbind-context

log = structlog.get_logger().bind(tenant="acme")

def charge(invoice):
    ilog = log.bind(invoice_id=invoice.id)
    ilog.info("charge_started")
    ilog.info("charge_done", cents=invoice.cents)

bind() returns a new logger and never mutates the original, so passing the parent around stays safe. unbind() and try_unbind() remove keys.

Stamp request context without passing a loggerrequest-context

from structlog.contextvars import bind_contextvars, clear_contextvars

async def middleware(request, call_next):
    clear_contextvars()
    bind_contextvars(request_id=request.headers.get("x-request-id"), path=request.url.path)
    return await call_next(request)

Requires structlog.contextvars.merge_contextvars as the first processor. Call clear_contextvars() at the start of each request or values leak between reused worker tasks.

Drop low-level calls cheaplylevel-filtering

import logging, structlog

structlog.configure(
    wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING),
)
log = structlog.get_logger()
log.debug("expensive", payload=build_payload())

Filtering happens in the bound logger before processors run, but your own arguments are still evaluated: build_payload() executes even though the line is discarded.

Write a processor that edits or drops eventscustom-processor

from structlog import DropEvent

def redact_and_filter(logger, method_name, event_dict):
    if event_dict.get("path") == "/healthz":
        raise DropEvent
    if "authorization" in event_dict:
        event_dict["authorization"] = "[redacted]"
    return event_dict

A processor is any callable taking (logger, method_name, event_dict) and returning a dict. Raising DropEvent discards the record with no output.

Log an exception with a structured tracebacklog-exceptions

log = structlog.get_logger()
try:
    charge(invoice)
except PaymentError:
    log.exception("charge_failed", invoice_id=invoice.id)

With processors.dict_tracebacks configured, the traceback lands as nested JSON instead of an embedded multi-line string, which is what makes it searchable in a log backend.

Route standard library logging through structlogstdlib-integration

import logging, structlog

structlog.configure(
    processors=[
        structlog.stdlib.add_log_level,
        structlog.stdlib.add_logger_name,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    logger_factory=structlog.stdlib.LoggerFactory(),
)

formatter = structlog.stdlib.ProcessorFormatter(
    foreign_pre_chain=[
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
    ],
    processors=[
        structlog.stdlib.ProcessorFormatter.remove_processors_meta,
        structlog.processors.JSONRenderer(),
    ],
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)

foreign_pre_chain only applies to records from libraries using plain logging; forget it and third-party lines arrive without a level or timestamp while yours look fine.

Log from async code without blocking the loopasync-logging

log = structlog.get_logger()

async def handler(request):
    await log.ainfo("request_received", path=request.path)

The a-prefixed methods run the sync logger in a thread pool, so they help when your output destination can block; plain log.info() in async code is fine when you write to stdout.

Assert on emitted log events in teststest-logs

from structlog.testing import capture_logs

def test_charge_logs_failure():
    with capture_logs() as logs:
        charge_or_fail(invoice)
    assert logs[0]["event"] == "charge_failed"
    assert logs[0]["invoice_id"] == invoice.id

capture_logs replaces the configured processors, so renderer-specific formatting is not exercised; it tests the event dict, not the output string.

Add file, function, and line numbercallsite-info

from structlog.processors import CallsiteParameter, CallsiteParameterAdder

structlog.configure(processors=[
    CallsiteParameterAdder([
        CallsiteParameter.FILENAME,
        CallsiteParameter.FUNC_NAME,
        CallsiteParameter.LINENO,
    ]),
    structlog.processors.JSONRenderer(),
])

Callsite lookup walks the stack on every record, so it is the one enricher worth turning off if logging shows up in a profile.

Alternatives

PackageRegistryPick it when
loguruPyPIYou want good-looking logs and file rotation immediately and do not care about structured fields.
python-json-loggerPyPIYou want JSON output from the stdlib logging module you already configured, with no new logging API.
eliotPyPIYou care about causal action trees across a workflow rather than a flat stream of events.