Structured JSON Logging with structlog
How to emit structured JSON logs in Python using structlog, including context binding, log levels, processors, and integration with standard logging.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
structlog produces structured logs — each log entry is a dictionary with keys for timestamp, level, event, and any context you bind. Unlike plain print() or logging.getLogger(), structlog outputs JSON that log aggregators (ELK, Loki, Datadog) can parse without regex. You bind request-scoped context (user ID, trace ID) once, and every subsequent log line includes it automatically.
When to Use
- Applications that ship logs to a log aggregator (ELK, Loki, Splunk, Datadog)
- Microservices where you need to trace a request across multiple services via correlation IDs
- APIs where you want to log request method, path, status code, and duration in every line
- Replacing
print()debugging with structured, filterable log output
When NOT to Use
- Simple scripts where
print()is sufficient — structlog adds a dependency and config - CLI tools that output human-readable text — use
rich.loggingorclick.echoinstead - High-throughput hot paths where logging itself is the bottleneck — sample or batch instead
Solution
Setup
pip install structlog
Basic structured logging
import structlog
logger = structlog.get_logger()
logger.info("user_logged_in", user_id=42, method="oauth")
logger.warning("rate_limit_approaching", user_id=42, remaining=5)
logger.error("payment_failed", order_id="ord_123", reason="card_declined")
Output (JSON):
{"event": "user_logged_in", "user_id": 42, "method": "oauth", "level": "info", "timestamp": "2026-07-05T10:30:00Z"}
Configuration with processors
import structlog
import logging
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
Context binding
logger = structlog.get_logger()
# Bind context that persists across all log calls
request_logger = logger.bind(request_id="req-abc-123", user_id=42)
request_logger.info("processing_order", order_id="ord_456")
request_logger.info("order_validated", items=3)
request_logger.warning("inventory_low", sku="widget-001", stock=2)
Every log line includes request_id and user_id automatically.
Using contextvars for async context
import structlog
from contextvars import ContextVar
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
def set_request_context(request_id: str, user_id: int):
structlog.contextvars.bind_contextvars(
request_id=request_id,
user_id=user_id,
)
def clear_request_context():
structlog.contextvars.clear_contextvars()
# In an ASGI middleware
async def logging_middleware(request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
set_request_context(request_id, user_id=extract_user_id(request))
try:
response = await call_next(request)
logger.info("request_completed", status=response.status_code, path=request.url.path)
return response
finally:
clear_request_context()
Integration with standard logging
import logging
import structlog
# Configure structlog
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
)
# Configure standard logging to route through structlog
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=logging.INFO,
)
# Third-party libraries using standard logging will also output JSON
logging.getLogger("urllib3").info("Connection pool created")
Log levels and filtering
import structlog
import logging
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING),
)
logger = structlog.get_logger()
logger.info("this_is_filtered") # Not emitted (below WARNING)
logger.warning("this_shows_up", key="value") # Emitted
Exception logging with traceback
logger = structlog.get_logger()
try:
result = 1 / 0
except ZeroDivisionError:
logger.exception("division_failed", operation="calculate_ratio")
Output includes the full traceback in the exception field.
Custom processor for sensitive data redaction
def redact_sensitive_data(logger, method_name, event_dict):
sensitive_keys = {"password", "api_key", "token", "credit_card"}
for key in list(event_dict.keys()):
if key.lower() in sensitive_keys:
event_dict[key] = "[REDACTED]"
return event_dict
structlog.configure(
processors=[
redact_sensitive_data,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
)
logger = structlog.get_logger()
logger.info("user_login", email="alice@example.com", password="secret123")
# Output: {"event": "user_login", "email": "alice@example.com", "password": "[REDACTED]", ...}
Performance timing processor
import time
def add_timing(logger, method_name, event_dict):
if "start_time" in event_dict:
event_dict["duration_ms"] = round((time.time() - event_dict.pop("start_time")) * 1000, 2)
return event_dict
structlog.configure(
processors=[
add_timing,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
)
logger = structlog.get_logger()
start = time.time()
# ... do work ...
logger.info("database_query", start_time=start, query="SELECT * FROM users")
Variants
Using structlog with FastAPI
from fastapi import FastAPI, Request
import structlog
import uuid
app = FastAPI()
logger = structlog.get_logger()
@app.middleware("http")
async def log_requests(request: Request, call_next):
request_id = str(uuid.uuid4())
structlog.contextvars.bind_contextvars(
request_id=request_id,
method=request.method,
path=request.url.path,
)
response = await call_next(request)
logger.info("request_completed", status_code=response.status_code)
structlog.contextvars.clear_contextvars()
return response
Using structlog with Celery
from celery import Celery
import structlog
app = Celery("tasks", broker="redis://localhost:6379")
logger = structlog.get_logger()
@app.task
def process_order(order_id: str):
logger.bind(task_id=app.current_task.request.id, order_id=order_id)
logger.info("processing_started")
# ... process ...
logger.info("processing_completed")
Best Practices
-
For a deeper guide, see High-Performance Logging with pino.
-
Use
contextvarsfor request-scoped context in async frameworks — it propagates correctly acrossawaitboundaries -
Always include a
timestampprocessor — log aggregators need it for ordering -
Add a
request_idortrace_idto every log line for distributed tracing correlation -
Use
logger.exception()(notlogger.error()) in exception handlers — it includes the traceback -
Configure structlog once at application startup, not per module
-
Use
cache_logger_on_first_use=Truefor performance in hot paths -
Redact sensitive fields (passwords, tokens, PII) with a custom processor
Common Mistakes
- Binding context on the root logger:
logger.bind()returns a new logger — it doesn’t mutate the root. Store the bound logger in a variable. - Not clearing contextvars: in async frameworks, context leaks between requests. Always clear in a
finallyblock. - Using string interpolation instead of kwargs:
logger.info(f"User {user_id} logged in")loses structure. Uselogger.info("user_logged_in", user_id=user_id). - Not configuring standard logging: third-party libraries (urllib3, boto3) use
logging. Without integration, their output is unstructured. - Logging at INFO level in hot loops: a loop running 10,000 times with an INFO log produces 10,000 log lines. Use DEBUG or sample.
FAQ
How do I output human-readable logs in development?
Use structlog.dev.ConsoleRenderer() instead of JSONRenderer():
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.dev.ConsoleRenderer(),
],
)
This prints colored, formatted output for local development.
Can I use structlog with Django?
Yes. Add a logging configuration in settings.py:
LOGGING = {
"version": 1,
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "json"},
},
"root": {"handlers": ["console"], "level": "INFO"},
}
Then configure structlog with structlog.stdlib.LoggerFactory() to route through Django’s logging.
How do I add a correlation ID across microservices?
Generate a UUID at the entry point, add it to outgoing HTTP headers, and bind it in the receiving service:
# Sender
logger = logger.bind(correlation_id=corr_id)
requests.post(url, headers={"X-Correlation-ID": corr_id})
# Receiver
corr_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
structlog.contextvars.bind_contextvars(correlation_id=corr_id)
What is the performance overhead of structlog?
structlog is lightweight — the JSON rendering happens only if the log level passes the filter. With make_filtering_bound_logger, below-threshold logs are no-ops. In production, expect <1ms per log call.
How do I ship structlog output to Loki or ELK?
Write JSON to stdout and let a log collector (Fluent Bit, Filebeat, Promtail) pick it up. The JSON format is already structured — no parsing rules needed.
Related Resources
High-Performance Logging with pino
How to use pino for fast structured JSON logging in Node.js, including log levels, child loggers, transports, and integration with Express and Fastify.
RecipeExpose Business Metrics with Prometheus
How to expose custom business metrics in Python using prometheus_client, including counters, gauges, histograms, summaries, and Flask integration.
RecipeDistributed Tracing with OpenTelemetry
How to implement distributed tracing in Python with OpenTelemetry SDK, including spans, context propagation, auto-instrumentation, and Jaeger export.
RecipeCustom Health Checks with Spring Boot Actuator
How to implement custom health indicators with Spring Boot Actuator, including database, Redis, external API checks, and Kubernetes readiness probes.
RecipeError Tracking with Sentry in Express
How to integrate Sentry for error tracking in Node.js Express applications, including error handlers, performance monitoring, release tracking, and source maps.
RecipeRotate Logs Daily with Winston
How to configure daily log rotation in Node.js using winston and winston-daily-rotate-file, including size limits, retention, compression, and transport combining.