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.
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.
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
- Most of your log volume comes from third-party libraries using the standard logging module: routing those through structlog means the ProcessorFormatter plus foreign_pre_chain setup, which is the single most confusing part of the project and easy to get subtly wrong
- You just want prettier logs with one import and no thinking: loguru gives you that in a line, where structlog asks you to understand processors, wrapper_class, and logger_factory before your config is correct
- You are on Python 3.8 or 3.9: 26.1.0 dropped both, so you are pinned to the 25.x line
- Your team writes log.info(f"user {uid} did {thing}") everywhere: structlog only pays off if people actually pass structured keys, and it will not stop them formatting strings
- You need a vendor-blessed, batteries-included agent: structlog renders and hands off, so shipping, batching, and sampling are still your problem or your collector's
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_dictA 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.idcapture_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
| Package | Registry | Pick it when |
|---|---|---|
| loguru | PyPI | You want good-looking logs and file rotation immediately and do not care about structured fields. |
| python-json-logger | PyPI | You want JSON output from the stdlib logging module you already configured, with no new logging API. |
| eliot | PyPI | You care about causal action trees across a workflow rather than a flat stream of events. |