Modern Python Logging Libraries: Architecture and Production Guide

Three libraries dominate production Python logging — the standard library, structlog, and Loguru — and the choice between them is an architecture decision, not a style preference. This guide is for backend engineers and SREs who have outgrown bare print() calls and ad-hoc string formatting, and now need machine-parsable, context-rich logs that survive a distributed system. It builds on the groundwork in Python logging fundamentals and structured data and links the focused guides that go deeper: the architectural trade-offs in the standard library versus third-party libraries, the processor-pipeline model in structlog architecture and setup, the sink model in Loguru configuration and sinks, and a head-to-head decision in structlog vs Loguru vs standard library logging. Logging is one of three signals: it pairs with distributed tracing in Python through shared trace_id correlation and with Python metrics and instrumentation when a log spike needs to be tied back to a request-rate or latency change.

Three logging front ends, one output pipeline A single application log call can enter through structlog's processor chain, Loguru's sink router, or the standard library's handler stack. Whichever front end is chosen, all three render newline-delimited JSON to stdout, which a collector reads and ships to a log store, so the choice of library never changes the wire format. your call site interchangeable front end one wire format log.info("evt", amount=42) structlog processor chain Loguru sink router stdlib logging handler stack stdout one JSON per line collector ships to log store NDJSON
The three libraries are alternative front ends to the same newline-delimited JSON output a collector ingests — which is why a service can switch front ends without changing its wire format.

The decision between these libraries is not about which prints the prettiest line in development. It is about how each one handles context propagation across await boundaries, how it serializes under load, how it filters before paying serialization cost, and how cleanly it bridges to OpenTelemetry. The sections below establish the shared architecture, then work through configuration, async behavior, transport, cost control, runnable code, and the mistakes that cause production incidents.

Key architectural principles

  • Separate event capture from rendering. The code that calls log.info("order_paid", amount=42) should not know whether the output is colored console text or JSON. Every modern stack enforces this split so you can change output format per environment without touching business logic.
  • Carry context out of band. Request-scoped data such as trace_id, user_id, and tenant belongs in context variables, not in every log call. The pipeline merges it into each record automatically.
  • Filter before you serialize. Level checks and sampling must run before JSON encoding, because encoding a record you then discard is pure waste at scale.
  • Keep the hot path non-blocking. Disk and network writes belong on a background thread fed by a bounded queue, so a slow sink applies backpressure instead of stalling the event loop.
  • Standardize field names. Aligning on OpenTelemetry semantic conventions means logs, traces, and metrics join on the same keys in your backend.
  • Own one configuration entry point. Whichever library you choose, exactly one module should configure it, at startup, and every other module should only ever acquire a logger — never reconfigure one.

Foundational architecture & logging standards

Every Python logging stack reduces to the same four stages: capture an event with structured key-value data, enrich it with context, filter it by level or sampling rule, and render it to a transport. The standard library models this as a Logger that creates a LogRecord, passes it through attached Filter objects, and hands it to one or more Handler instances, each owning a Formatter. structlog models it as an ordered list of processor callables that transform a single mutable event_dict until a terminal renderer turns it into a string. Loguru collapses the model into a single global logger that fans records out to registered sinks, each with its own level, format, and filter. The architectural choices behind each model are dissected in the standard library versus third-party comparison.

The same four stages, three different implementations Every Python logging stack performs the same four stages. Capture creates the event, enrich merges request context, filter drops records below the level, and render turns the record into a line. The matrix names the object or callable that performs each stage in structlog, in Loguru, and in the standard library. structlog Loguru stdlib logging 1 Capture 2 Enrich 3 Filter 4 Render log.info("evt", k=v) bound logger logger.info(msg) one global logger Logger.makeRecord() LogRecord object merge_contextvars processors mutate dict bind() / contextualize() record extra dict extra= plus Filter contextvars by hand make_filtering_ bound_logger(INFO) level= per sink filter= callable logger + handler level Filter objects JSONRenderer terminal processor serialize=True or a custom sink Formatter.format() one per handler
Read a row to compare libraries, a column to follow one library's path: the stages are identical, only the object that performs each stage changes.

The unifying standard is the OpenTelemetry log data model. It defines a log record as a timestamp, a severity, a body, and a set of attributes, with optional trace_id and span_id fields that correlate the record to a span. When your logs carry those two identifiers and name their fields after semantic conventions such as service.name, http.request.method, and http.response.status_code, a backend can pivot from a latency graph to the exact log lines emitted during the slow request. This is the same correlation key that ties logging to distributed tracing in Python, and the mechanics of stamping it onto a record are covered in adding trace IDs to log records.

Severity is the other place the standard pays off. The OpenTelemetry model defines a numeric severity range that maps cleanly onto Python's five levels, so a structlog add_log_level processor or a standard library levelname both land on a value your backend understands without per-service translation tables. The same numeric ladder is what makes mapping Python log levels to syslog mechanical rather than a judgment call when a legacy aggregator sits in the path. Aligning on that mapping early means an alert rule written against severity >= ERROR behaves identically whether the line came from a stdlib logger in a legacy module or a structlog call in a new one. The discipline is to treat field names and severities as a contract the whole fleet shares, not a per-team choice, because the moment two services disagree on whether the key is status or http.response.status_code, cross-service queries silently return partial results.

A practical consequence of the data model is that the log body should stay a stable, low-cardinality string while the variable parts move into attributes. Writing log.info("payment_failed", reason="insufficient_funds", amount=42) instead of log.info(f"payment failed: insufficient funds, $42") keeps the event name groupable — every failure of that kind shares one body — while the specifics remain queryable as fields. This is the single habit that most separates logs a backend can aggregate from logs that are just searchable text, and all three libraries make it the path of least resistance. It is also what lets you count events without a metrics pipeline: a backend that indexes event="payment_failed" can graph its rate directly, whereas a formatted sentence forces a regex over raw text on every query.

JSON has won as the wire format for production logs because it is unambiguous to parse and every aggregator indexes it natively. The cost is serialization: encoding a dictionary to JSON costs measurable CPU compared to writing a preformatted string, which is exactly why level filtering must happen first. Console-friendly, colorized output is a development convenience that should never reach production stdout, where a shipping agent expects one JSON object per line. Two practical constraints follow. Values must be JSON-serializable or pass through a fallback encoder — a naive json.dumps on a datetime, Decimal, or ORM instance raises inside the logging path, and an exception raised while logging an exception is a genuinely miserable failure to debug. And every record must be exactly one line: an embedded newline from a traceback or a user-supplied string splits into two records at the agent, one of which is unparsable, so tracebacks belong in a single escaped field rather than as raw multi-line output.

The choice between the three libraries usually comes down to organizational shape rather than raw capability. A single team running one or two services tends to favor Loguru because its global logger and logger.add() setup get structured, rotating, non-blocking output in a handful of lines with no class hierarchy to learn. A platform team running dozens of services tends to favor structlog because a shared processor chain can be packaged, versioned, and imported across the fleet, guaranteeing every service emits the same schema and the same trace correlation without each team reinventing it. The standard library remains the right answer when adding a dependency is genuinely costly — a library meant to be embedded in other people's applications, or a constrained environment — because it asks the consumer for nothing, and because its dictConfig schema is the one configuration format every Python operator already recognizes. None of these is a permanent commitment: because all three converge through stdlib handlers, a service can start on one and migrate later without rewriting its call sites, which is the topic of the head-to-head comparison and of the step-by-step migration from standard logging to structlog.

Instrumentation strategy & configuration

Configure logging exactly once, during application bootstrap, before any worker threads or the event loop start. Reconfiguring a live logging system invalidates cached loggers and races with in-flight records. In structlog this means a single structlog.configure() call; in the standard library it means one logging.config.dictConfig() call; in Loguru it means a logger.remove() to drop the default handler followed by your logger.add() calls.

Where that call lives matters as much as how many times it runs. Put it in a dedicated module — observability/logging.py — exposing a single configure_logging() function, and call it from the process entry point: the ASGI lifespan startup hook, the WSGI application factory, the Celery setup_logging signal, or __main__. Never configure at import time in a module that other modules import, because import order then silently determines your logging behavior, and a test that imports things in a different order gets different output. Frameworks complicate this: Django reads LOGGING from settings and applies dictConfig itself, gunicorn installs its own handlers, and Celery hijacks the root logger unless told not to. In each case the fix is the same — let the framework's documented hook call your one function, rather than fighting it with a second configuration pass.

Drive environment differences from configuration, not from if branches scattered through the code. The clean pattern is a single boolean — is this a TTY or a production container — that selects the terminal renderer for local development and the JSON renderer everywhere else. Keep the processor or handler list identical otherwise, so the only thing that changes between laptop and production is the last rendering step. Read the level and the renderer choice from environment variables (LOG_LEVEL, LOG_FORMAT) so an operator can raise verbosity on one pod without a code change, and validate them at startup so a typo fails loudly rather than silently defaulting to WARNING.

What one configure_logging() call has to do At process start a single configure_logging function runs before any worker thread or event loop. It configures the front end, attaches a bridge to the standard library root logger so third-party packages such as urllib3 and botocore join the same chain, and finally branches on whether the process is attached to a TTY, choosing the console renderer for local development or the JSON renderer for a container. Everything before that final step is identical in both environments. process start __main__ / lifespan configure_logging() called exactly once before threads / loop bridge stdlib root InterceptHandler or ProcessorFormatter third-party loggers urllib3 · botocore · ORM ConsoleRenderer TTY · local dev JSONRenderer container · prod once then same schema branch on isatty() or LOG_FORMAT
One entry point, one bridge, one branch: only the final renderer differs between a laptop and a container.

Capturing logs from libraries you do not control is part of the same configuration job. Third-party packages log through the standard library, so a service that only configures structlog or Loguru will silently lose every line a dependency emits, or worse, emit it in a different, unparsable format. Bridge them: route stdlib records into structlog with structlog.stdlib.ProcessorFormatter, or into Loguru with an InterceptHandler attached to the root logger. The result is one schema across your code and every dependency, which is what makes a single ingestion rule at the collector possible. The mechanics of that bridge are worked through in the standard library versus third-party comparison, and applied end to end for a real framework in structlog JSON logging in Django.

Bridging also means taming the noisy defaults those libraries ship with. urllib3, botocore, asyncio, and most ORMs log at DEBUG in volumes that dwarf your application's own output, so set explicit per-logger levels for the handful of packages that matter and leave the rest at the root level. Set propagate deliberately as well: attaching a handler to a named logger while leaving propagation on sends every record to both that handler and the root's, which is the usual cause of perfectly duplicated log lines.

Request-scoped context is the part most teams get wrong. Bind it once at the start of a request, in middleware, and clear it at the end. Both structlog's bind_contextvars/clear_contextvars and a standard library contextvars.ContextVar carry that data through every nested function call and await without being threaded through arguments. For structlog specifically, the binding API and its lifecycle are covered in depth in structlog architecture and setup and its companion guide on binding context variables in structlog.

The clearing step is not optional. In a server that reuses worker threads or coroutines across requests, context bound during one request that is never cleared will linger and attach to the next request's logs, producing the same kind of cross-contamination that thread-locals cause. The robust pattern wraps the bind and clear in middleware so the clear runs even when the handler raises, typically a try/finally or an ASGI middleware whose teardown always executes. Treat the bound context as request-lifetime state with a guaranteed cleanup, exactly as you would a database session.

Decide deliberately what belongs in bound context versus what stays a per-call field. Identifiers that should appear on every line of a request — request_id, trace_id, tenant, user_id — belong in bound context so you never repeat them. Values specific to a single event — the order_id being processed, the status_code returned — belong as keyword arguments on that one call. Overbinding makes every line heavier and can mask which event actually carried a value; underbinding forces you to thread identifiers through call signatures. The right split keeps each line both complete and minimal. Static facts about the process — service.name, service.version, deployment.environment, region, pod name — belong in neither: bind them once at configuration time as resource-level fields, or let the collector attach them, so they cost nothing per request and stay consistent across every signal.

Concern stdlib logging structlog Loguru
Configure once at dictConfig() structlog.configure() logger.remove() + logger.add()
Structured fields manual extra= native key-value native via bind()/extra
JSON output custom Formatter JSONRenderer processor serialize=True or custom sink
Context propagation contextvars by hand merge_contextvars processor logger.bind() / contextualize()
Non-blocking I/O QueueHandler + QueueListener route through stdlib queue enqueue=True
File rotation RotatingFileHandler delegated to stdlib rotation= / retention= on a sink
Capturing library logs native ProcessorFormatter bridge InterceptHandler bridge
Bridges to OTel LoggingHandler processor injects ids custom sink or stdlib bridge

Async & concurrency patterns

The defining hazard in async services is context leakage. A threading.local value set during one request can be read by a coroutine serving a different request, because many coroutines share one OS thread. The fix is contextvars.ContextVar, whose values are bound to the logical execution context rather than the thread, so each task and each coroutine sees its own snapshot. structlog's merge_contextvars processor is built on exactly this, which is why bound context survives await correctly while thread-local approaches silently cross-contaminate.

Context copying has one edge that surprises people: a contextvars.Context is copied when a task is created, not shared. Values bound before asyncio.create_task() are visible inside the task, but anything the task binds afterwards stays local to it and never propagates back to the parent. That is usually what you want — a fan-out of ten concurrent calls should each be able to add their own attempt field without racing — but it means a background task spawned outside the request scope starts with an empty context and will emit logs without a request_id unless you bind one explicitly. Threads are the opposite trap: a plain threading.Thread or a ThreadPoolExecutor worker does not inherit the caller's context at all, so work handed to run_in_executor loses the request context unless you capture it with contextvars.copy_context() and run the callable inside it.

Why contextvars and not threading.local On the left, two coroutines running on the same event loop thread share a single threading.local slot, so request B reads the trace_id that request A wrote. On the right, contextvars gives each task its own copied context, so the two requests never see each other's values. A ThreadPoolExecutor worker is called out separately: it starts with an empty context unless the caller captures it with copy_context. threading.local — one shared slot contextvars — one context per task request A coroutine request B coroutine one OS thread threading.local trace_id = A B logs A's trace_id task A context copied task B context copied trace_id = A trace_id = B not inherited ThreadPoolExecutor worker empty unless copy_context() is used
Thread-local state is shared by every coroutine on the thread; a context variable is copied per task — and never copied into a thread-pool worker at all.

The second hazard is blocking I/O on the event loop. Writing a log line to a file or socket is a synchronous syscall; under load it stalls the loop and inflates p99 latency for every concurrent request. The remedy is to hand records to a bounded queue and let a dedicated background thread drain it. The standard library expresses this as a QueueHandler on the hot path feeding a QueueListener that owns the real handlers; Loguru expresses the same idea with enqueue=True on a sink. In both cases the queue must be bounded so a slow downstream applies backpressure rather than growing memory without limit.

structlog occupies a deliberate middle ground here: it renders the event dictionary to a string but does not own the final write. That is by design — it delegates output to whatever logger_factory you configure. So the non-blocking story for structlog is to route through the standard library with stdlib.LoggerFactory() and put the QueueHandler/QueueListener pair behind it. The string formatting happens inline, which is cheap once the level filter has already dropped sub-threshold records, and only the I/O moves to the background thread. Loguru, by contrast, queues the entire record before formatting, so the formatting cost also moves off the hot path. Neither is strictly better; they trade where the work happens for how much code you write.

Process boundaries need their own treatment. Under gunicorn's prefork model or a multiprocessing.Pool, every child inherits the parent's file descriptors, and two processes appending to the same rotating file handler will interleave partial writes and fight over the rename during rotation. The safe patterns are to write to stdout and let the platform demultiplex, or to funnel every process into one writer through a multiprocessing.Queue — the approach detailed in thread-safe logging across multiple processes and in the rotation-specific guidance on log rotation in Python. Loguru's enqueue=True handles the multi-process case natively because its queue is process-safe, which is one of its genuinely strong points.

A subtle async pitfall is shutdown ordering. A background queue means records are in flight when the process receives a termination signal, so a naive exit drops them. The fix is to flush on shutdown: call listener.stop() on a standard library QueueListener, or logger.complete() and logger.remove() on Loguru, inside your application's shutdown hook. In a containerized service this matters because the most interesting logs — the ones explaining why the process is terminating — are exactly the ones most likely to be lost if the queue is not drained before exit. The same reasoning applies to os.fork(): a queue's drain thread does not survive the fork, so a child inheriting a QueueListener built in the parent enqueues records nobody will ever write. Build the queue and its listener after the fork, in the per-worker init hook.

import logging
import logging.handlers
import queue

# Bounded queue: drops to backpressure instead of unbounded RAM growth
log_queue: queue.Queue = queue.Queue(maxsize=10_000)

queue_handler = logging.handlers.QueueHandler(log_queue)
stream_handler = logging.StreamHandler()  # the real, blocking sink
listener = logging.handlers.QueueListener(log_queue, stream_handler)

root = logging.getLogger()
root.addHandler(queue_handler)  # hot path only enqueues
root.setLevel(logging.INFO)
listener.start()  # background thread owns the blocking write

logging.getLogger("app").info("non_blocking_log_emitted")
listener.stop()

Expected Output:

non_blocking_log_emitted

Network & protocol integration

In a containerized deployment, the application should not talk to your log store directly. It writes JSON to stdout, and a node-level agent or sidecar reads that stream and ships it. This keeps the application stateless, removes network failure handling from the hot path, and lets the agent buffer and retry independently. Avoid library handlers that open their own TCP or HTTP connection to a remote endpoint inside the request process — a collector outage then becomes an application latency spike.

Two transports, one of which you must choose On the left the application writes newline-delimited JSON to stdout and a node agent or sidecar buffers it to disk and ships it onward. On the right the OpenTelemetry LoggingHandler feeds a BatchLogRecordProcessor whose bounded queue exports over gRPC to a collector. Both paths carry the same trace_id and span_id, and enabling both in the same environment ingests every line twice. agent scrapes stdout in-process OTLP export application → stdout one JSON object per line node agent / sidecar disk buffer, retry, restart-safe log store ingest rule parses NDJSON OTel LoggingHandler attached to the root logger BatchLogRecordProcessor bounded queue, drops when full collector over gRPC in-memory queue lost on restart trace_id + span_id identical on both paths both at once every line ingested twice pick one per environment
Both topologies produce correlatable logs; running them together is the one combination that only doubles the bill.

To export logs as OTLP rather than relying on stdout scraping, attach the OpenTelemetry LoggingHandler to the standard library root logger. It converts each LogRecord into an OTLP log record, attaches the active trace_id and span_id, and hands it to a BatchLogRecordProcessor that batches and exports over gRPC or HTTP to a collector. This is the same provider-and-processor lifecycle described for traces in OpenTelemetry SDK setup, and it inherits the same rule: build the provider once, shut it down explicitly, and never let a forked child inherit a dead exporter thread. The critical detail is to keep one export path: if both stdout scraping and OTLP export are active you will ingest every line twice. Choose the agent-scrapes-stdout model or the SDK-pushes-OTLP model per environment, not both.

The BatchLogRecordProcessor has the same tuning surface as its tracing counterpart: a maximum queue size, a maximum batch size, and an export interval. Under sustained high volume the queue can fill faster than the exporter drains it, at which point the processor drops records rather than blocking the application — a deliberate trade that protects request latency at the cost of completeness. Size the queue for your peak burst and accept that, beyond it, dropping logs is preferable to stalling traffic. If completeness matters more than the loss of an in-process buffer, the agent-scrapes-stdout model is more robust, because the agent persists to disk and survives an application restart that would lose an in-memory OTLP queue.

Correlation is the whole point of the protocol work, and it depends on identifiers that arrive from upstream rather than being invented locally. A service that reads the incoming traceparent header, as described in context propagation and baggage, and then stamps the resulting trace_id onto its logs produces records that join to a trace spanning every hop. A service that generates its own request identifier instead produces logs that are only searchable within itself. If you must keep a local request_id for a legacy system, emit both — the local one for continuity and the trace_id for the join.

There is also a structural reason to prefer writing to stdout in containerized and serverless platforms: the runtime already captures stdout and routes it, so you inherit retry, buffering, and backpressure handling for free instead of reimplementing it inside the request process. A library handler that opens its own connection couples your request latency to the health of a remote endpoint, which is the opposite of what you want when that endpoint is the thing failing. Reserve in-process OTLP export for environments where you control the collector's locality — a node-local agent or sidecar reachable over loopback — so a connection failure is a local, fast failure rather than a cross-network stall.

Data volume control & cost management

Log volume is a direct, often surprising, line item on the observability bill. Three levers control it. First, filtering by level before serialization: a make_filtering_bound_logger(logging.INFO) in structlog or a per-handler level in the standard library means DEBUG records are discarded before any JSON is produced. Second, sampling high-frequency events: a chatty per-item loop should log a summary, or one in N items, not every iteration. Third, dropping payloads: never log full request or response bodies, which both inflate cost and risk leaking PII.

Where log volume actually disappears A funnel of four narrowing bars. One million raw events per minute enter; the level filter, applied before serialization, removes most of them; sampling the chatty INFO tier removes most of the remainder; redaction and field-length capping keep the same count but shrink each line; and retention routing then splits the survivors into a long-retention error and audit tier and a short routine INFO window. raw events emitted by the fleet 1 000 000 / min level filter, before serialization 240 000 / min sample the chatty INFO tier 38 000 / min redact + cap field length −45% bytes errors + audit 90-day retention routine INFO 7-day window never rendered never sample errors same count, shorter lines
Each stage is cheaper than the one below it: a record dropped by the level filter never pays for serialization, transport, or storage.

Redaction belongs in the pipeline, not at the call site, so it cannot be forgotten. A processor or formatter that walks the record and masks keys named password, authorization, token, or ssn guarantees that no log line carries those values regardless of what a developer passed. Combine redaction with a hard cap on field length so a single oversized value cannot blow up a log line. These same volume principles — sample early, cap cardinality, drop what you cannot afford — carry directly over to Python metrics and instrumentation, where unbounded label values cause the analogous cost explosion.

It helps to think about volume in tiers. A small set of high-value events — request completed, error raised, state transition — should always be logged because each one carries debugging weight. A large set of low-value events — per-row processing in a batch, cache hits, retry attempts — should be summarized or sampled, because their value is statistical rather than individual. Logging a count and a representative example of the low-value tier costs a constant amount regardless of throughput, while logging each occurrence scales linearly with traffic and dominates the bill during exactly the traffic spikes when you can least afford it. The level filter handles the coarse cut; an explicit if i % 1000 == 0 or a sampling processor handles the fine one.

Sampling logs is more dangerous than sampling traces, so bias it. Errors and warnings should never be sampled — they are rare by construction and each one is evidence. Sampling belongs on the high-rate INFO and DEBUG tier, and it should be trace-consistent where possible: if a request's trace is sampled, keep all of its logs, and if it is not, keep only the errors. That rule produces complete narratives for the requests you can actually investigate, instead of a uniformly thinned stream where every interesting request is missing half its lines.

Retention is the other half of cost. A line kept for ninety days costs far more than the same line kept for seven, so route by value: errors and audit events into long-retention storage, routine INFO into a short window, and DEBUG either dropped at the edge or kept only in a sampled, short-lived stream. Encoding that routing in the field schema — a stable severity plus an explicit audit=true flag — lets the collector make the retention decision deterministically rather than guessing from free text. It also pays to measure before optimizing: group a day of ingest by logger and event and the top ten rows almost always account for most of the bill, usually including one debug line somebody left enabled. Fixing those ten is cheaper than any global policy. The goal across all of these levers is the same: pay for the logs that change a decision, and stop paying for the ones that only confirm the system is doing what you already expected.

Production code examples

The event_dict after each processor Five rows, one per processor in the configured chain. The call site supplies event and status_code. merge_contextvars adds request_id from the request-scoped context, inject_trace_id adds trace_id and span_id when the span is sampled, add_log_level adds level, TimeStamper adds an ISO timestamp, and JSONRenderer replaces the dictionary with a single newline-delimited JSON line on stdout. processor, in order event_dict after that step merge_contextvars event, status_code, request_id inject_trace_id event, status_code, request_id, trace_id, span_id add_log_level … trace_id, span_id, level TimeStamper(iso) … span_id, level, timestamp JSONRenderer one NDJSON line on stdout — dict is gone
Each processor receives the dictionary the previous one returned; the level filter runs before any of them, so a dropped DEBUG record never enters this chain at all.

structlog processor pipeline with OpenTelemetry trace context injection

This configuration merges contextvars, injects trace identifiers from the active span only when it is sampled, filters at INFO, and renders JSON. It is async-safe and suitable for a service that already runs OpenTelemetry tracing.

import logging
import structlog
from opentelemetry import trace
from opentelemetry.trace import TraceFlags


def inject_trace_id(logger, method_name, event_dict):
    # Pull ids from the active span; only attach for sampled traces
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid and ctx.trace_flags & TraceFlags.SAMPLED:
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict


structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,   # request-scoped context
        inject_trace_id,                            # OTel correlation ids
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),        # terminal renderer
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,                 # required for hot paths
)

logger = structlog.get_logger()
logger.info("request_processed", status_code=200)

Tested with structlog>=24.1.0,<26.0.0. With cache_logger_on_first_use=True and the contextvars processor, this pipeline is safe across event loops because no per-call processor reconstruction or thread-local state is involved.

Expected Output:

{"status_code": 200, "event": "request_processed", "level": "info", "timestamp": "2026-06-19T10:30:00.000000Z"}

Loguru async-safe JSON sink

Loguru reaches the same JSON-on-stdout outcome with far less code. A custom sink shapes the payload, and enqueue=True moves the write to a background thread so the event loop never blocks. Sink construction in depth — including rotation, retention, and filtering per sink — is covered in implementing custom sinks in Loguru.

import sys
import json
from loguru import logger


def json_sink(message):
    # message.record holds the structured fields Loguru captured
    record = message.record
    payload = {
        "level": record["level"].name,
        "timestamp": record["time"].isoformat(),
        "message": record["message"],
        "context": record.get("extra", {}),
    }
    sys.stdout.write(json.dumps(payload, default=str) + "\n")


logger.remove()  # drop the default colorized stderr handler
logger.add(
    json_sink,
    level="INFO",
    enqueue=True,    # background queue -> non-blocking, thread/async-safe
    backtrace=False,
    diagnose=False,  # never True in production: leaks variable values
)

logger.bind(version="1.4.2").info("service_started")

Tested with loguru>=0.7.0,<0.8.0. Setting diagnose=True in production is a common mistake because it embeds local variable values in tracebacks, which leaks secrets into logs.

Expected Output:

{"level": "INFO", "timestamp": "2026-06-19T10:30:00.000000+00:00", "message": "service_started", "context": {"version": "1.4.2"}}

Standard library dictConfig with a JSON formatter and OTLP export

When adding a dependency is not an option, the standard library gets you the same shape: a JSON formatter, a queue in front of the real handler, and the OpenTelemetry handler attached to the root so records leave as OTLP log records carrying the active trace context.

import json
import logging
import logging.config
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource

RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__)


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "event": record.getMessage(),
            "logger": record.name,
        }
        # Anything passed via extra= becomes a top-level structured field
        payload.update({k: v for k, v in record.__dict__.items() if k not in RESERVED})
        if record.exc_info:  # keep tracebacks on ONE line
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, default=str)


provider = LoggerProvider(resource=Resource.create({"service.name": "checkout"}))
provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
set_logger_provider(provider)  # one provider, built once at bootstrap

logging.config.dictConfig({
    "version": 1,
    "disable_existing_loggers": False,   # keep third-party loggers alive
    "formatters": {"json": {"()": JsonFormatter}},
    "handlers": {
        "stdout": {"class": "logging.StreamHandler", "formatter": "json"},
        "otlp": {"()": lambda: LoggingHandler(logger_provider=provider)},
    },
    "loggers": {"urllib3": {"level": "WARNING"}},  # silence a noisy dependency
    "root": {"level": "INFO", "handlers": ["stdout", "otlp"]},
})

logging.getLogger("checkout").info("order_paid", extra={"order_id": "A-1187"})
provider.shutdown()  # flush the batch before the process exits

Tested with opentelemetry-sdk>=1.24.0,<2.0.0 and opentelemetry-exporter-otlp-proto-grpc>=1.24.0,<2.0.0. Ship either the stdout handler or the otlp handler in a given environment, not both, unless you intend the collector to receive each event twice.

Expected Output:

{"timestamp": "2026-06-19T10:30:00+0000", "level": "INFO", "event": "order_paid", "logger": "checkout", "order_id": "A-1187"}

Common mistakes

From symptom to remedy Start from the symptom that production logs are unusable and follow the branch that matches. Lines that are not valid JSON at the agent point to a single renderer and single-line exception fields. Missing dependency output points to a standard library bridge. A request_id appearing on the wrong request points to contextvars bound and cleared in middleware. Latency that tracks log volume points to a bounded queue with a background writer. production logs are unusable start from the symptom lines are not valid JSON at the agent dependency logs never appear request_id lands on the wrong request p99 latency tracks log volume one renderer escape tracebacks into one field bridge stdlib root ProcessorFormatter or InterceptHandler bind contextvars in middleware, clear in a finally block bounded queue QueueHandler + QueueListener, or enqueue
Four symptoms, four distinct fixes — none of which is switching logging library.
  • Synchronous logging blocking the event loop. Writing directly to disk or a socket from an async handler stalls the loop and inflates p99 latency for every concurrent request. Route records through a bounded QueueHandler/QueueListener pair or Loguru's enqueue=True so the write happens on a background thread.
  • Using threading.local for request context in async code. Thread-local values bleed across coroutines that share an OS thread, so one request's trace_id shows up on another's logs. Always use contextvars or the structlog merge_contextvars processor instead.
  • Serializing records you then discard. Placing the level filter after, rather than before, JSON rendering pays full serialization cost for every dropped DEBUG line. Filter by level first with make_filtering_bound_logger or a handler-level threshold.
  • Logging full request and response bodies. Dumping raw payloads inflates storage cost and leaks PII. Log a bounded summary and apply redaction as a pipeline step so no call site can leak secret-named fields.
  • Exporting the same log twice. Running stdout scraping and OTLP export at once double-counts every line and doubles cost. Pick one transport per environment.
  • Reconfiguring logging at runtime. Calling structlog.configure() or dictConfig() after startup races with in-flight records and invalidates cached loggers. Configure exactly once during bootstrap.
  • Leaving disable_existing_loggers at its default. dictConfig disables every logger created before the call unless you set it to False, which silently mutes any dependency imported at the top of your entry point. Set it explicitly and set per-logger levels for the noisy packages instead.
  • Emitting multi-line records. A raw traceback or a user-supplied newline splits one event into several lines, and the agent parses the continuation lines as broken JSON. Serialize exceptions into a single escaped field.

Frequently Asked Questions

Should I use structlog or Loguru for a new microservice?

Choose structlog when you need a composable processor pipeline, strict JSON output, and tight OpenTelemetry trace correlation across many services. Choose Loguru when a small team wants zero-boilerplate setup, rich exception formatting, and built-in file rotation in a single process. Both can route through the standard library so you are never locked in.

How do I align Python logs with OpenTelemetry standards?

Emit JSON, inject trace_id and span_id from the active span into every record, and name your fields after OTel semantic conventions such as service.name and http.response.status_code. For full pipeline export, attach the opentelemetry LoggingHandler so records become OTLP log records alongside your traces.

What log level strategy should production SREs use?

Default services to INFO, keep DEBUG behind an environment variable or feature flag, and reserve ERROR and CRITICAL for actionable, paged failures. Filter levels as early as possible in the pipeline so dropped records never pay serialization cost, and support reloading the level without a redeploy.

How do I keep logging from becoming a performance bottleneck?

Move serialization and I/O off the request path with a bounded queue, either the standard library QueueHandler plus QueueListener or Loguru's enqueue=True. Filter by level before rendering, cap queue size to apply backpressure, and sample or drop high-volume DEBUG events under load.

Can I migrate to structlog or Loguru without rewriting every log call?

Yes. Keep the standard library logging API as the facade for existing modules, then route stdlib records through a structlog ProcessorFormatter or a Loguru InterceptHandler. New code can call the richer API directly while old code keeps working unchanged.

Does the GIL make logging a throughput bottleneck in threaded services?

Rendering a record holds the GIL for the duration of the JSON encode, which is short but real at high rates, and the blocking write releases it. That is why level filtering before serialization matters more than the library you pick: the cheapest record is the one never rendered. With a bounded queue, the hot path only does an append and the encode plus write happen on a background thread, so request-handling threads contend far less.

Which library handles exceptions and tracebacks best in production?

Loguru has the richest built-in exception rendering, including variable values when diagnose is enabled, which is excellent locally and dangerous in production because it writes secrets into logs. structlog's format_exc_info processor and dict_tracebacks produce structured, machine-parsable traceback fields, which is usually the better production choice because the backend can group on exception type without regex.