structlog — JSON Logging with Context

Configure structlog with processors, renderers, and context binding for production Python applications

10m 10m reading Lab included

The Problem

Python’s built-in logging module produces flat text strings. Adding context requires string formatting: logger.info(f"Created item {item_id} for user {user_id}"). This context is embedded in text — not queryable.

structlog produces structured key-value log events that are machine-parseable.

The Full Configuration

# app/logging_config.py
import logging
import sys
import structlog
from app.config import settings


def setup_logging() -> None:
    log_level = getattr(logging, settings.log_level.upper(), logging.INFO)

    # Shared processors — run on every log event
    shared_processors = [
        structlog.contextvars.merge_contextvars,
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.UnicodeDecoder(),
    ]

    # Choose renderer based on environment
    if settings.log_format == "json":
        renderer = structlog.processors.JSONRenderer()
    else:
        renderer = structlog.dev.ConsoleRenderer(colors=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 for stdout
    stdout_formatter = structlog.stdlib.ProcessorFormatter(
        processors=[
            structlog.stdlib.ProcessorFormatter.remove_processors_meta,
            renderer,
        ],
    )

    root_logger = logging.getLogger()
    root_logger.handlers.clear()

    stdout_handler = logging.StreamHandler(sys.stdout)
    stdout_handler.setFormatter(stdout_formatter)
    root_logger.addHandler(stdout_handler)
    root_logger.setLevel(log_level)

    # Suppress noisy loggers
    logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
    logging.getLogger("uvicorn.error").setLevel(logging.WARNING)


def get_logger(name: str) -> structlog.stdlib.BoundLogger:
    return structlog.get_logger(name)

Processor Pipeline

Every log event flows through processors in order:

Log Event → merge_contextvars → filter_by_level → add_logger_name
         → add_log_level → TimeStamper → StackInfoRenderer
         → UnicodeDecoder → JSONRenderer → Output
Processor What It Does
merge_contextvars Adds request-scoped context (request_id, method, path)
filter_by_level Drops events below configured log level
add_logger_name Adds logger field (module name)
add_log_level Adds level field
TimeStamper(fmt="iso") Adds ISO 8601 timestamp
JSONRenderer Serializes everything to JSON

Using the Logger

from app.logging_config import get_logger

logger = get_logger(__name__)

# Sync logging
logger.info("item_created", item_id="abc-123", name="Widget")

# Async logging (inside async functions)
await logger.ainfo("item_created", item_id="abc-123", name="Widget")

Output:

{
  "timestamp": "2026-04-11T10:15:23.456789Z",
  "level": "info",
  "logger": "app.api.routes.items",
  "event": "item_created",
  "item_id": "abc-123",
  "name": "Widget"
}

JSON vs Console Renderer

JSON (LOG_FORMAT=json) — for production:

{"timestamp": "2026-04-11T10:15:23Z", "level": "info", "event": "item_created", "item_id": "abc-123"}

Console (LOG_FORMAT=console) — for development:

2026-04-11T10:15:23Z [info     ] item_created          item_id=abc-123

Same data, different rendering. Set via environment variable — no code change.

stdlib Integration

structlog wraps Python’s standard logging module. This means:

  • Third-party libraries that use logging still work
  • Log levels work the same (DEBUG, INFO, WARNING, ERROR)
  • Handlers (stdout, file, syslog) are standard logging handlers

Next Step

In the next lesson, we add request-scoped logging with correlation IDs — trace a single request across all log lines using middleware.