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.
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: setcolorize=Falseon the sink and verify raw JSON output.- A structlog processor returning
None. Symptom:KeyError: trace_iddownstream or silently dropped records. Cause: a processor that returnsevent_dictonly inside anelsebranch returnsNoneon the other path. Remediation: ensure every code path ends withreturn event_dict. TypeError: Object of type datetime is not JSON serializable. Cause: relying on implicit datetime serialization. Remediation: passdefault=strtojson.dumps, or coerce withstructlog.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.