Structured logging cheatsheet.

Install

uv add structlog

Basic setup

import logging, sys, structlog

logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.INFO)

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer() if PROD else structlog.dev.ConsoleRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,
)

log = structlog.get_logger()

JSON in prod; pretty colors in dev.

Log

log.info("user_created", user_id=42, email="[email protected]")
log.warning("rate_limited", user_id=42, count=100)
log.error("payment_failed", order_id=1, error="timeout")
log.exception("unhandled")        # includes exc_info from current handler

Output (JSON):

{"timestamp":"2026-05-17T...","level":"info","event":"user_created","user_id":42,"email":"[email protected]"}

bind / new

# Bind context for a chain
log_user = log.bind(user_id=42)
log_user.info("step_a")
log_user.info("step_b")
# both have user_id=42

# new = bind + reset
log2 = log.new(request_id="abc")

contextvars (request-scoped)

# At request start
structlog.contextvars.bind_contextvars(request_id=rid, user_id=str(user.id))

# Anywhere downstream
log.info("event")     # automatically includes request_id, user_id

# At request end
structlog.contextvars.clear_contextvars()

ContextVar-based; safe for asyncio.

Trace correlation (OTEL)

from opentelemetry import trace

def add_trace(_, __, event_dict):
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.trace_id:
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict

# Add to processors before JSONRenderer

PII redaction

import re

EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
PHONE = re.compile(r"\+?\d[\d\s()-]{7,}")

def redact(_, __, ed):
    for k, v in list(ed.items()):
        if isinstance(v, str):
            v = EMAIL.sub("[EMAIL]", v)
            v = PHONE.sub("[PHONE]", v)
            ed[k] = v
    return ed

Add to processors.

Filter by level

structlog.configure(
    wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING),
)
# Drops INFO and DEBUG

Lazy formatting

log.info("user", user=lambda: expensive_serialize(u))
# Only serializes if log is emitted

stdlib logging interop

import logging

# Route stdlib logging through structlog
structlog.configure(
    processors=[
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.stdlib.BoundLogger,
    logger_factory=structlog.stdlib.LoggerFactory(),
)

# Configure root logging
formatter = structlog.stdlib.ProcessorFormatter(
    processor=structlog.processors.JSONRenderer(),
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.addHandler(handler)

Sampling

import random

def sample(_, __, ed):
    if ed.get("level") == "info" and random.random() < 0.1:
        return ed
    elif ed.get("level") in ("warning", "error", "critical"):
        return ed
    raise structlog.DropEvent

10% of info; all warnings/errors.

Performance

structlog is fast. Two tips:

  • cache_logger_on_first_use=True (config arg).
  • Avoid heavy serialization in event_dict values.

FastAPI integration

@app.middleware("http")
async def log_middleware(request, call_next):
    rid = request.headers.get("x-request-id") or str(uuid.uuid4())
    structlog.contextvars.bind_contextvars(
        request_id=rid,
        path=request.url.path,
        method=request.method,
    )
    try:
        return await call_next(request)
    finally:
        structlog.contextvars.clear_contextvars()

Common mistakes

  • Mixing logging.info(...) (stdlib) with log.info(...) (structlog) — inconsistent.
  • PII in event_dict — leaked to logs.
  • Heavy work in event_dict — even if log dropped.
  • No clear_contextvars per request — context leaks.

Read this next

If you want my structlog + OTEL + FastAPI setup, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .