Structured logs represent an event as fields instead of a sentence that must be parsed later. Operators can filter by request_id, operation, duration, and result in an observability system.
Configure JSON output
import logging
import structlog
logging.basicConfig(level=logging.INFO)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.JSONRenderer(),
]
)
log = structlog.get_logger("payments")
log.info("payment_looked_up", payment_id="pg_123", duration_ms=42)
Use stable event names and fields with consistent types. The Python logging tutorial covers levels and handlers that still apply.
Bind request context safely
Bind a request_id at request entry with structlog.contextvars and clear context on exit. Internal functions gain correlation without another argument in every signature. Verify propagation explicitly when work moves to a thread or process.
Entity IDs may be useful, but passwords, tokens, authorization headers, cookies, and personal content do not belong in logs. Log storage often has broader access and different retention from the primary database.
Capture events in tests and assert their name and required fields, not JSON key order. Treat schema changes as interface changes when dashboards or alerts consume them.
The official structlog documentation, accessed July 22, 2026, covers processors, JSON rendering, standard logging integration, and context variables. Structure improves searchability, but useful logs still require clear events, controlled cardinality, and a privacy policy.
Understand the processor pipeline
Each log call begins as an event dictionary. Processors receive it in order, enrich or transform it, and a renderer finally serializes it. Ordering matters: level and timestamp processors must run before JSONRenderer, which ends the chain.
Local development can replace only the renderer:
import os
renderer = (
structlog.processors.JSONRenderer()
if os.getenv("APP_ENV") == "production"
else structlog.dev.ConsoleRenderer(colors=True)
)
Keep all fields before the renderer identical across environments. Otherwise a production dashboard may expect duration_ms while developers have only seen elapsed. Colors help in an interactive terminal but should be disabled when output is redirected.
Add stack and exception processors when needed. Do not log only str(exc), which loses the exception type and traceback. Within an exception handler, log.exception("order_failed", order_id=order_id) preserves diagnostic information. Keep the event name stable and put changing details in fields.
Integrate standard logging
Dependencies usually emit records through Python's logging module. If an application configures an unrelated structlog output, third-party events will have a different format. ProcessorFormatter can process both paths into consistent output. Exact setup depends on handlers and the application server, but logging should be configured once at the process entry point.
Duplicate lines commonly mean that a named logger has its own handler and also propagates to the root logger. Inspect handlers, propagate, and ASGI server settings before adding filters. A reusable library should not call basicConfig() during import because the host application owns destination and level.
Treat levels as part of the event contract. Use debug for detailed diagnosis, info for normal milestones, warning for a recoverable condition, error when an operation failed, and exception when a traceback is useful. An expected validation response should not create an alarming traceback for every request.
Propagate request context
Clear context at request entry so a reused worker cannot leak fields from earlier work, then bind safe identifiers:
from structlog.contextvars import bind_contextvars, clear_contextvars
async def middleware(request, call_next):
clear_contextvars()
request_id = request.headers.get("X-Request-ID") or make_id()
bind_contextvars(request_id=request_id, method=request.method)
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
Validate a client-supplied ID and cap its length before logging it. A caller must not be able to inject line breaks or thousands of characters. Bind domain fields such as order_id only for the relevant operation, then unbind them so later events are not contaminated.
Context variables follow asynchronous tasks in the same context, but propagation across threads, queues, and processes is not universally automatic. Include a correlation ID in a queued message and bind it again in the worker. Do not forward an entire context blindly because it may contain fields inappropriate for another system.
Establish an event schema
Use predictable names and types: duration_ms should remain numeric, success boolean, and timestamps UTC. Do not alternate among "42 ms", 42, and 0.042 for one measurement. A small catalog for critical events helps alert authors and reduces differences between services.
Control cardinality. A normalized route="/users/{id}" is easier to aggregate than every concrete URL. Resource IDs may remain useful in searchable event data, but they do not need to become indexed metric labels. Control volume too: one batch summary with count, failures, and duration is often more useful than one event per item.
Protect sensitive data
Prefer an allowlist of approved fields for sensitive operations. Attempting to remove every spelling of password, token, or authorization data afterward is fragile. Never log entire headers, connection strings, request bodies, or user objects. Omit personal identifiers when they are unnecessary, or apply pseudonymization that matches the organization's policy.
Redaction must occur before rendering and before an external handler receives the dictionary. A processor can replace known keys with "[REDACTED]", but it must handle nested structures and have tests. Log storage also needs suitable retention, access controls, and deletion procedures.
Test and operate the configuration
structlog.testing.capture_logs() is convenient for focused tests, although it bypasses some configured processors. To test the real pipeline, direct output to a stream, parse each JSON line, and assert required fields and types. Cover exceptions, context cleanup between requests, and the absence of representative secrets.
In production, writing JSON to standard output and letting the runtime collect it is usually simpler than managing files inside the web process. Rotation, retention, and delivery then belong to the platform. If the destination fails, explicitly decide whether logging blocks, buffers, or drops events so an observability outage does not unexpectedly stop the service.
Treat the logging configuration as production code. Keep it in one module, initialize it once during startup, and review processor ordering whenever a field or renderer changes. Exercise a representative event locally and in staging, then confirm that the collector preserves numbers, booleans, timestamps, exception details, and correlation fields with their intended types. A valid JSON line is not enough if the ingestion pipeline silently converts every value to text. Document the stable event names used by dashboards and alerts so a refactor does not disable monitoring without failing an application test.
Start with a limited set of events that answer operational questions: which operation failed, for which resource, how long it took, and which correlation joins related work. Revisit queries and alerts regularly. Structured logging earns its cost through faster diagnosis, not through the amount of JSON stored.