structlog: Structured Logging in Python (2026)
Plain text log lines such as User 42 placed order A-1001 are easy to write and painful to search. Once your service runs in containers and ships logs to Loki, Elasticsearch, Datadog or CloudWatch, you want every line to be a machine-readable event with named fields. structlog is the most popular Python library for exactly that: you log an event name plus key-value pairs, and a small chain of processors turns them into JSON (or pretty console output in development). This tutorial was tested with structlog 26.1.0 on Python 3.13.5.
Why structlog in 2026?
- Key-value events instead of formatted strings, so filters like
user_id=42orlevel=errorjust work in your log platform. - Context binding: attach
request_idoruser_idonce and every later log line carries it. - Async-safe request context through
contextvars, ideal for FastAPI, Starlette and asyncio workers. - Fast: filtering bound loggers skip disabled levels with almost zero cost.
- Works side by side with the standard
loggingmodule, so third-party libraries end up in the same JSON stream.
Install
pip install structlog
Check your versions:
import sys, structlog
print("Python", sys.version.split()[0], "| structlog", structlog.__version__)
# Python 3.13.5 | structlog 26.1.0
Step 1: JSON logging with bound context
The script below configures structlog once at startup. Each log call passes through the processors in order: merge request context, add the level, add a UTC ISO timestamp, then render JSON. The logger only emits INFO and above, so the debug call is dropped.
import logging
import sys
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(sys.stdout),
cache_logger_on_first_use=True,
)
log = structlog.get_logger()
# Request-scoped context (e.g. set once in middleware)
structlog.contextvars.bind_contextvars(request_id="req-7f3a")
# Logger-scoped context
user_log = log.bind(user_id=42)
user_log.info("order_created", order_id="A-1001", total=59.9, items=3)
user_log.debug("cart_details", skus=["X1", "X2"]) # below INFO: dropped
try:
1 / 0
except ZeroDivisionError as exc:
user_log.error("payment_failed", provider="stripe", error=repr(exc))
structlog.contextvars.clear_contextvars()
log.warning("cache_miss", key="user:42")
Real output
Running python app_logging.py printed exactly these three lines (one JSON object per line, ready for any log shipper):
{"user_id": 42, "order_id": "A-1001", "total": 59.9, "items": 3, "event": "order_created", "request_id": "req-7f3a", "level": "info", "timestamp": "2026-10-14T02:14:54.813883Z"}
{"user_id": 42, "provider": "stripe", "error": "ZeroDivisionError('division by zero')", "event": "payment_failed", "request_id": "req-7f3a", "level": "error", "timestamp": "2026-10-14T02:14:54.814012Z"}
{"key": "user:42", "event": "cache_miss", "level": "warning", "timestamp": "2026-10-14T02:14:54.814047Z"}
Notice three things. The user_id bound with log.bind() appears on both user events. The request_id from bind_contextvars() appears until clear_contextvars() is called, so the final cache_miss line no longer has it. And the cart_details debug event never reached the output.
How the processor chain works
merge_contextvarscopies values bound withbind_contextvars()into the event dict. In a web app you call it in middleware at the start of each request.add_log_leveladds"level";TimeStamper(fmt="iso", utc=True)adds a sortable UTC timestamp.JSONRenderer()must be last because it turns the dict into a string. For faster encoding you can passserializer=orjson.dumpstogether withBytesLoggerFactory; see our guide to fast JSON with orjson.make_filtering_bound_logger(logging.INFO)builds a logger class whose disabled methods do nothing, which keeps debug calls cheap in production.cache_logger_on_first_use=Truefreezes the configuration after the first log call for extra speed.
Step 2: Send standard logging through structlog
Libraries such as uvicorn, httpx or SQLAlchemy use the standard logging module. With ProcessorFormatter both your structlog events and those foreign log records share the same processors and come out as the same JSON format.
import logging
import sys
import structlog
shared = [
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso", utc=True),
]
structlog.configure(
processors=shared + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(),
],
)
)
root = logging.getLogger()
root.addHandler(handler)
root.setLevel(logging.INFO)
structlog.get_logger("billing").info("invoice_sent", invoice=981, amount=120.0)
logging.getLogger("thirdparty.http").warning("retrying request %s", "GET /v1/rates")
Real output:
{"invoice": 981, "amount": 120.0, "event": "invoice_sent", "level": "info", "logger": "billing", "timestamp": "2026-10-14T02:14:54.885188Z"}
{"event": "retrying request GET /v1/rates", "level": "warning", "logger": "thirdparty.http", "timestamp": "2026-10-14T02:14:54.885437Z"}
The first line came from structlog, the second from a plain logging.getLogger() call, yet both have level, logger and timestamp fields in one consistent JSON shape.
Development vs production output
In development, swap the last processor for structlog.dev.ConsoleRenderer() to get colored, aligned, human-friendly lines with pretty tracebacks. A common pattern is to choose the renderer from an environment variable:
import os
renderer = (structlog.processors.JSONRenderer()
if os.getenv("LOG_FORMAT") == "json"
else structlog.dev.ConsoleRenderer())
Production tips
- Log events, not sentences: use short snake_case event names such as
order_createdand put the details in fields. - Bind a
request_idin middleware and return it in a response header, so a user report can be traced to every log line. Our FastAPI error handling and logging best practices guide shows where that middleware fits. - For exceptions, call
log.exception("event")and addstructlog.processors.dict_tracebacksbeforeJSONRendererto get structured tracebacks. - Never log secrets: add a small processor that removes keys like
passwordortokenfrom the event dict. - Test your logging with
structlog.testing.capture_logs(), which returns the event dicts so you can assert on fields. It pairs nicely with property-based testing with Hypothesis.
Conclusion
structlog gives Python services clean, searchable JSON logs with very little code: one configure() call, a processor chain, and bind() for context. Add the ProcessorFormatter bridge and even third-party library logs join the same structured stream, which makes debugging production issues dramatically faster.