Loguru vs structlog for Microservices: Exact JSON Config for Distributed Tracing

You are choosing between Loguru and structlog for a microservice that must emit deterministic JSON to a log aggregator while carrying W3C trace context across async boundaries. This guide isolates the exact configuration each library needs and where their concurrency and overhead models diverge. It builds on Structlog Architecture and Setup and is part of the Modern Python Logging Libraries Deep Dive; for the full three-way picture including the standard library, see structlog vs Loguru vs standard library logging.

structlog vs Loguru pipelines structlog runs a contextvars merge, processor chain, and JSON renderer. Loguru runs bind, sink routing, and a serialize step. Both converge on a single stdout JSON stream consumed by the aggregator. structlog Loguru merge_contextvars processor chain JSONRenderer logger.bind sink routing serialize / callable stdout JSON to aggregator
Both pipelines converge on one stdout JSON stream; they differ in how context is attached and where serialization happens.

Prerequisites

Pin both libraries and an OpenTelemetry API for span-context extraction.

pip install \
  "structlog>=24.1.0,<26.0.0" \
  "loguru>=0.7.0,<0.8.0" \
  "opentelemetry-api>=1.30.0,<2.0.0"

No environment variables are required for the snippets below; both write JSON to stdout so a platform collector can ingest them directly.

Implementation

For a microservice the comparison reduces to three questions that a benchmark alone will not answer: how does request context attach and survive across await, how much CPU does each library spend per record under sustained load, and how cleanly does the JSON match your aggregator's expected shape. The implementation below answers the first by configuring both libraries to emit an identical flat schema; the overhead and shape differences fall out of how each gets there.

The decisive difference is context propagation. structlog uses Python contextvars natively, so a value bound inside a request handler stays isolated per coroutine and survives await points without being passed explicitly. Loguru's logger.bind returns an immutable bound logger you must thread through, or you extract the active span context synchronously at the point of logging. Both must read trace identifiers from opentelemetry.context and format them per the W3C specification: a 32-character hex trace id and a 16-character hex span id.

Step 1 — Configure structlog with an OTel-extraction processor. Add a processor that reads the current span and writes 32 and 16 hex digit identifiers into the event dict, then renders JSON. Order matters: context merge first, level and timestamp next, the renderer last.

import structlog
from opentelemetry import trace
from opentelemetry.trace import INVALID_SPAN_CONTEXT


def extract_otel_context(logger, method_name, event_dict):
    """Inject W3C-compliant trace IDs from the active span."""
    ctx = trace.get_current_span().get_span_context()
    if ctx == INVALID_SPAN_CONTEXT or not ctx.is_valid:
        event_dict["trace_id"] = "0" * 32
        event_dict["span_id"] = "0" * 16
    else:
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict  # every path must return the dict


structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        extract_otel_context,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(20),
    logger_factory=structlog.PrintLoggerFactory(),
)

structlog.get_logger().info("request_processed", user_id="usr_992", status=200)

Expected Output:

{"level": "info", "trace_id": "00000000000000000000000000000000", "span_id": "0000000000000000", "timestamp": "2024-01-15T10:30:00.123456Z", "event": "request_processed", "user_id": "usr_992", "status": 200}

Step 2 — Configure Loguru with a custom JSON sink. Loguru has no processor chain, so the equivalent logic lives inside a callable sink. Extract the span context there, build a flat payload, and write to stdout with colorization disabled so no ANSI bytes corrupt the JSON.

import sys
import json
from datetime import datetime, timezone
from loguru import logger
from opentelemetry import trace
from opentelemetry.trace import INVALID_SPAN_CONTEXT


def otel_json_sink(message):
    record = message.record
    ctx = trace.get_current_span().get_span_context()
    if ctx == INVALID_SPAN_CONTEXT or not ctx.is_valid:
        trace_id, span_id = "0" * 32, "0" * 16
    else:
        trace_id = format(ctx.trace_id, "032x")
        span_id = format(ctx.span_id, "016x")
    payload = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "level": record["level"].name,
        "message": record["message"],
        "trace_id": trace_id,
        "span_id": span_id,
        "module": record["name"],
        "extra": record["extra"],
    }
    sys.stdout.write(json.dumps(payload, default=str) + "\n")
    sys.stdout.flush()


logger.remove()
logger.add(otel_json_sink, level="INFO", colorize=False,
           backtrace=False, diagnose=False)
logger.bind(request_id="req_881").info("cache_hit", )

Expected Output:

{"timestamp": "2024-01-15T10:30:00.456789+00:00", "level": "INFO", "message": "cache_hit", "trace_id": "00000000000000000000000000000000", "span_id": "0000000000000000", "module": "__main__", "extra": {"request_id": "req_881"}}

Configuration options

Dimension structlog Loguru
Async context propagation contextvars, implicit per coroutine bind is explicit; thread it through
Where transformation runs processor chain inside a callable sink
JSON output JSONRenderer() processor serialize=True or callable sink
Trace context injection dedicated processor logic inside the sink
stdlib bridging structlog.stdlib.LoggerFactory logger.remove then logger.add
Sustained-throughput overhead lower; deferred formatting higher; record formatting plus routing

The serialization detail that bites teams: structlog's JSONRenderer operates on a plain dict it controls, while Loguru's serialize=True wraps a full record and nests everything under text and record keys, which complicates schema validation downstream. Writing a callable sink (as above) sidesteps that nesting. For deeper sink configuration, see implementing custom sinks in Loguru.

The practical decision rule that follows from the table: choose structlog when the service is async-heavy and you want trace context to propagate implicitly across coroutines without threading a logger object through every call, and when you will route stdlib library logs (SQLAlchemy, Celery, the web framework) through the same JSON pipeline using its stdlib bridge. Choose Loguru when developer ergonomics and a one-line setup matter more than ambient async context, when most logging is synchronous, or when you value the built-in rotation, retention, and enqueue-based dispatch that structlog leaves to the stdlib handler underneath it. Neither choice is reversible cheaply once a fleet of services standardizes on one schema, so settle the JSON field names — trace_id, span_id, severity_number, body versus message — before either library ships to production. Mixed field names across services are the most common cause of broken aggregator queries during a partial rollout.

Verification

Both snippets above emit a single line of valid JSON with zero-valued trace identifiers when run outside an active span, which is the correct fallback. Confirm an aggregator can parse the line and that the trace fields are exactly 32 and 16 hex characters.

import json

line = '{"trace_id": "00000000000000000000000000000000", "span_id": "0000000000000000"}'
parsed = json.loads(line)
assert len(parsed["trace_id"]) == 32
assert len(parsed["span_id"]) == 16
print("trace field widths valid")

Expected Output:

trace field widths valid

Common mistakes

  • json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0). Cause: Loguru colorization left ANSI escape codes in the stream feeding a strict JSON parser. Remediation: set colorize=False on the sink and verify raw JSON output.
  • A structlog processor returning None. Symptom: KeyError: trace_id downstream or silently dropped records. Cause: a processor that returns event_dict only inside an else branch returns None on the other path. Remediation: ensure every code path ends with return event_dict.
  • TypeError: Object of type datetime is not JSON serializable. Cause: relying on implicit datetime serialization. Remediation: pass default=str to json.dumps, or coerce with structlog.processors.TimeStamper(fmt="iso") before the renderer.

Frequently Asked Questions

Which library has lower CPU overhead for JSON serialization in high-throughput microservices?

structlog typically shows lower CPU overhead because its pre-compiled processor chain avoids wrapping a standard logging record and defers formatting to the final render. Loguru adds measurable latency under sustained high request rates due to internal record formatting and sink routing.

How do I propagate OpenTelemetry trace IDs without blocking the async event loop?

Use contextvars with structlog so trace context attaches per coroutine automatically. For Loguru, extract the span context synchronously at the middleware entry point before yielding to the async handler, and never perform blocking I/O inside the logging pipeline.

Can I run both libraries side by side during a gradual migration?

Yes, but route both to the same stdout sink and standardize the JSON schema, including consistent W3C trace context field names. Otherwise your aggregator sees two incompatible record shapes for the same service.

Is bind() in Loguru async-safe the way structlog contextvars are?

logger.bind returns an immutable bound logger, which is safe to pass explicitly, but it does not propagate implicitly across await points the way structlog's contextvars-based binding does. For ambient async context you must thread the bound logger through or patch in the context yourself.