structlog vs Loguru vs Standard Library Logging

Choosing a logging library is one of the earliest and most consequential decisions in a Python service, because it shapes how every downstream system parses, indexes, and correlates your telemetry. This guide compares the three options backend engineers actually weigh in production: Python's built-in logging module, structlog's processor-pipeline architecture, and Loguru's sink-based configuration. It is part of the Modern Python Logging Libraries Deep Dive and builds on the architectural trade-offs covered in Python standard library vs third-party logging, then narrows to a framework-level verdict in choosing a logging library for FastAPI. The goal is a defensible decision, not a feature list.

Logging library comparison matrix A four-row grid comparing the standard library, structlog, and Loguru on configuration model, native structured JSON, per-request context binding, and which component owns the actual write. stdlib logging structlog Loguru Configuration dictConfig declarative or code structlog.configure() code only logger.add(sink) code only Structured JSON custom Formatter you write it JSONRenderer flat, fully yours serialize=True nested under record Request context Filter + contextvar wired by hand bind_contextvars merged by processor logger.bind() or contextualize() Owns the write yes handler tree no delegates to stdlib yes sinks, enqueue=True
The four axes that decide the argument: how you configure it, whether structured JSON is native, how per-request context is bound, and who performs the actual write.

Prerequisites

Install both third-party candidates so you can run the same workload through all three back ends. Pin every dependency to a tested range, because both libraries have changed rendering defaults across minor releases:

pip install "structlog>=24.1.0,<26.0.0" "loguru>=0.7.0,<0.8.0"
# The standard library logging module ships with CPython; no install needed.

The equivalent declaration in pyproject.toml, which is what should actually land in your repository:

[project]
requires-python = ">=3.11"
dependencies = [
  "structlog>=24.1.0,<26.0.0",   # processor pipeline + flat JSON renderer
  "loguru>=0.7.0,<0.8.0",        # sink model + enqueue/rotation built in
]

The comparison assumes Python 3.11 or newer, where contextvars is mature and exception groups render cleanly. Two environment variables are enough to drive every configuration in this guide, and using them keeps the three setups genuinely comparable:

export LOG_LEVEL=INFO      # threshold applied before any serialization work
export LOG_FORMAT=json     # "json" in deployed environments, "console" on a developer TTY

Concept & architecture

The three libraries embody three different philosophies, and understanding those philosophies predicts how each behaves under load. Philosophy is not academic here: it dictates how hard it is to get flat JSON, how context flows through async code, and how much of the work the library does for you versus how much you assemble yourself. A team that picks on syntax alone usually re-litigates the decision six months later when log volume and correlation requirements grow.

The standard library logging module is a layered system of loggers, handlers, filters, and formatters wired together through a hierarchy keyed by dotted logger names. Its strength is ubiquity: every third-party package logs through it, and the logging.config.dictConfig schema lets you reconfigure the entire tree declaratively without touching application code, as covered in configuring logging with dictConfig. That declarative configuration is genuinely valuable in regulated or ops-driven environments, where the logging setup is treated as deployment configuration rather than code. Its weakness is that a LogRecord is fundamentally a formatted string plus loose attributes. Structured output is bolted on through a custom Formatter, the approach detailed in structured logging with the Python standard library, and propagation of per-request context is your problem to solve, typically with a Filter that reads a contextvar and injects fields onto each record. The module is also famously easy to misconfigure: the difference between configuring the root logger and a named logger, or between propagate=True and False, accounts for a large share of "my logs disappeared" incidents.

structlog inverts the model. Instead of formatting a string, you build an event dictionary that flows through an ordered list of processors. Each processor is a plain callable that receives the logger, the method name, and the event dict, and returns a (possibly mutated) event dict. The final processor renders the dict, typically with JSONRenderer or ConsoleRenderer. Because the unit of work is a dictionary rather than a string, structured logging is the default rather than an add-on, and the processor list is the single, readable place where enrichment, filtering, and rendering are defined. structlog deliberately integrates with the standard library: through structlog.stdlib.ProcessorFormatter you can run stdlib LogRecord objects through the same processor chain, so library logs and your own logs share one rendering pipeline. The composability also means cross-cutting concerns such as trace-id injection or PII redaction become a single processor you insert once rather than a change scattered across every call site, which is why the structlog architecture and setup guide treats the chain as the central design artifact.

Loguru optimizes for ergonomics. There is a single pre-configured logger object, and you configure outputs by calling logger.add(sink, ...). A sink can be a file path, a stream, a coroutine, or any callable — the extension point explored in implementing custom sinks in Loguru. Loguru bakes in features that are tedious to assemble elsewhere: rotation, retention, compression, colorized output, rich exception tracebacks with variable values, and serialize=True for one-line JSON. Its model is code-only configuration; there is no dictConfig equivalent, which is a deliberate trade: you gain a tiny, discoverable API at the cost of declarative, deployment-time reconfiguration. Loguru's logger.bind returns a child logger carrying extra fields, and logger.opt(...) toggles per-call behavior such as lazy evaluation or exception capture, so the ergonomic surface stays small while remaining flexible.

Underneath the philosophical differences sit three concrete axes that decide most real arguments. The first is who owns the write: the stdlib handler tree and Loguru both perform I/O themselves, while structlog renders a line and hands it to something else, which is why a structlog deployment is really "structlog plus a handler strategy". The second is where context lives: attributes on a LogRecord, a contextvar-backed dict merged by a processor, or a bound child logger. The third is what the wire format looks like — an arbitrary string, a flat object you fully control, or Loguru's fixed record envelope. Every table further down this page is a projection of those three axes.

One log call, three write paths Each lane follows a single log call through five stages: call site, record, enrich and filter, render, and write. A dashed rule before the final stage marks the write boundary — the standard library handler tree and the Loguru sink cross it themselves, while structlog hands the rendered line to a foreign handler. call site record enrich + filter render write stdlib logging log call logging.info() LogRecord msg + extra Filter inject contextvar Formatter JSON string Handler stdout / file handler tree owns the write structlog processors log call log.info(k=v) event dict plain mapping processors merge, level, ts renderer JSONRenderer stdlib handler queue + stream structlog hands off here Loguru sinks log call logger.info() record bind() extras sink filter level per sink serialize enqueue=True sink file / stdout sink worker owns the write
The same event through each library. The dashed rule is the write boundary: the standard library and Loguru cross it inside the library, while structlog stops at the rendered line and hands it to a handler you chose.

Step-by-step implementation

The clearest way to compare is to emit the same structured event from each library and read the bytes that come out.

  1. Standard library. Build a JSON formatter and attach it to a handler. Structured fields ride in the extra dict, which the formatter has to promote onto the payload itself.
import json
import logging

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "level": record.levelname.lower(),
            "event": record.getMessage(),
            "logger": record.name,
        }
        # Promote anything attached via extra= into the payload.
        if hasattr(record, "order_id"):
            payload["order_id"] = record.order_id
        return json.dumps(payload)

handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])

logging.getLogger("orders").info("order_validated", extra={"order_id": "ORD-992"})

Expected Output:

{"level": "info", "event": "order_validated", "logger": "orders", "order_id": "ORD-992"}
  1. structlog. Configure the processor chain once at startup and pass key-value pairs directly to the log call. No formatter subclass is involved, and the field set is open rather than enumerated in advance.
import logging
import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,      # per-request fields
        structlog.processors.add_log_level,           # "level": "info"
        structlog.processors.TimeStamper(fmt="iso"),  # "timestamp": "..."
        structlog.processors.JSONRenderer(),          # terminal renderer
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,
)

log = structlog.get_logger("orders")
log.info("order_validated", order_id="ORD-992")

Expected Output:

{"order_id": "ORD-992", "event": "order_validated", "level": "info", "timestamp": "2026-06-19T09:14:02.512Z"}
  1. Loguru. Replace the default sink with a serialized one and bind context fields. Note that logger.remove() is mandatory: Loguru ships with a pre-installed stderr sink, and forgetting to drop it is the usual cause of every line appearing twice.
import sys
from loguru import logger

logger.remove()  # drop the default stderr sink, or every line is emitted twice
logger.add(sys.stdout, serialize=True, level="INFO")

logger.bind(order_id="ORD-992").info("order_validated")

Expected Output:

{"text": "order_validated\n", "record": {"level": {"name": "INFO"}, "message": "order_validated", "extra": {"order_id": "ORD-992"}, "time": {"repr": "2026-06-19 09:14:02.512+00:00"}}}
  1. Compare the shapes, not the APIs. Loguru's serialize=True wraps your fields under record.extra and includes its full record model, whereas structlog gives you a flat object you control completely, and the stdlib gives you exactly what your formatter wrote. That flatness matters when you index logs in Elasticsearch or Loki: a query for order_id:"ORD-992" works against the flat shapes but must become record.extra.order_id against Loguru's default envelope, and every dashboard, alert, and saved search inherits that path. It is the single most common reason teams pick structlog for greenfield services. If you prefer Loguru's ergonomics but need a flat schema, write a serializing sink instead of using serialize=True.
import json
import sys
from loguru import logger

def flat_sink(message) -> None:
    r = message.record
    payload = {
        "level": r["level"].name.lower(),
        "event": r["message"],
        "logger": r["name"],
        "timestamp": r["time"].isoformat(),
        **r["extra"],                      # lift bound fields to the top level
    }
    sys.stdout.write(json.dumps(payload) + "\n")

logger.remove()
logger.add(flat_sink, level="INFO")
logger.bind(order_id="ORD-992").info("order_validated")

Expected Output:

{"level": "info", "event": "order_validated", "logger": "__main__", "timestamp": "2026-06-19T09:14:02.512000+00:00", "order_id": "ORD-992"}
  1. Decide the level threshold once, and apply it before serialization. All three libraries filter, but they filter in different places: the stdlib checks the logger's effective level and then each handler's level, structlog's make_filtering_bound_logger short-circuits before any processor runs, and Loguru compares against the level of each sink. Drive all three from the same LOG_LEVEL environment variable so a debug-level rollout does not accidentally leave one library chatty. The mapping between named levels across libraries and downstream systems is covered in log levels and severity mapping, which matters because Loguru adds TRACE and SUCCESS levels that have no stdlib equivalent.
Where each library applies the level threshold Three timelines from call site to write. The standard library checks the logger level first but builds a LogRecord before the handler level check; structlog's filtering bound logger rejects sub-threshold events before the event dict exists; Loguru builds a full record with caller introspection before any sink level is compared. level check build / serialize hand off to I/O stdlib logger level LogRecord built handler level json.dumps write structlog bound-logger level event dict built processor chain JSONRenderer write Loguru record + caller info sink level serialize enqueue to worker write everything to the left of the level-check dot is paid on every call, including the events you drop
The threshold check moves earlier or later per library, and everything before it is work you pay for even on suppressed events — which is why structlog's filtering bound logger is the cheapest suppression path.

Configuration reference

The first table maps the decision axes; the second lists the specific parameters worth setting deliberately in production.

Dimension Standard library structlog Loguru
Configuration model dictConfig / code code (structlog.configure) code (logger.add)
Unit of work LogRecord (string) event dict record dict
Native structured output no (custom formatter) yes (JSONRenderer) yes (serialize=True)
Output flatness depends on formatter flat, fully controlled nested under record
Per-request context manual / filters bind_contextvars logger.bind / contextualize
Async handoff QueueHandler + listener contextvars + stdlib queue enqueue=True
Catches third-party logs yes (native) yes (via ProcessorFormatter) only via InterceptHandler
Rotation / retention RotatingFileHandler via stdlib handlers built in
Rich exception tracebacks no optional processor yes (with values)
Owns the write path yes no (delegates) yes
Dependency footprint zero one pure-Python package one pure-Python package
Parameter Library Type Default Production value
cache_logger_on_first_use structlog bool False True
wrapper_class structlog callable BoundLogger make_filtering_bound_logger(INFO)
terminal processor structlog callable ConsoleRenderer JSONRenderer() off a TTY
logger_factory structlog callable PrintLoggerFactory stdlib.LoggerFactory()
serialize Loguru bool False True (or a flat custom sink)
enqueue Loguru bool False True
diagnose Loguru bool True False — it prints variable values
rotation / retention Loguru str None "100 MB" / "14 days"
disable_existing_loggers stdlib dictConfig bool True False
propagate stdlib bool True True, with one handler at the root
handler wrapping stdlib direct write QueueHandler + QueueListener
level all three str WARNING / DEBUG from LOG_LEVEL

Two entries deserve emphasis. diagnose=True is Loguru's default and it renders the values of local variables inside tracebacks — wonderful in development, a data-leak vector in production where those locals may hold tokens or customer records. And disable_existing_loggers defaults to True in dictConfig, which silently deactivates every logger created before configuration ran, including ones created at import time by your dependencies.

The production configuration surface, per library Three columns of parameter chips: structlog.configure keys, Loguru logger.add keys, and standard library dictConfig keys, each showing the default and the value to ship. The chips for Loguru's diagnose and the standard library's disable_existing_loggers are outlined in dashes because their defaults are wrong in production. structlog.configure() cache_logger_on_first_use False → True wrapper_class make_filtering_bound_logger(INFO) terminal processor JSONRenderer() off a TTY logger_factory stdlib.LoggerFactory() logger.add(sink, ...) serialize False → True, or a flat sink enqueue False → True diagnose True → False, it prints locals rotation / retention None → "100 MB" / "14 days" dictConfig / handlers disable_existing_loggers True → False, it mutes imports propagate True, one handler at the root handler wrapping QueueHandler + QueueListener level read from LOG_LEVEL dashed outline = a default that is actively wrong in production
The handful of parameters worth setting deliberately. Two defaults are traps: Loguru's diagnose renders local variable values into production tracebacks, and dictConfig silently deactivates every logger created before it ran.

Performance & overhead

Performance comparisons between logging libraries are easy to get wrong, because the dominant cost is almost always serialization and I/O, not the library's dispatch overhead. With that caveat, a few durable truths hold. The standard library is fastest for a trivial, already-formatted string sent to a no-op handler, because its hot path is short and heavily optimized. The moment you add a custom JSON Formatter, that advantage shrinks to noise, since json.dumps dominates. structlog adds the cost of walking its processor list, but cache_logger_on_first_use=True and make_filtering_bound_logger keep that cheap: level filtering happens before any processor runs, so sub-threshold events are discarded for the price of one comparison. Loguru carries the highest fixed per-call cost because it constructs a rich record with caller introspection, but serialize=True and enqueue=True move the expensive part off the calling thread entirely.

If you want your own numbers rather than someone else's, measure the same event through all three back ends against a null sink so you isolate library cost from disk cost:

# bench.py — pip install "structlog>=24.1.0,<26.0.0" "loguru>=0.7.0,<0.8.0"
import logging
import timeit

import structlog
from loguru import logger

sink = open("/dev/null", "w")  # measure library cost, not disk latency

std = logging.getLogger("bench")                 # 1. stdlib + JSON formatter
handler = logging.StreamHandler(sink)
handler.setFormatter(JsonFormatter())            # from the step-by-step section
std.addHandler(handler)
std.setLevel(logging.INFO)
std.propagate = False

structlog.configure(                             # 2. structlog, cached + filtering
    processors=[
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    logger_factory=structlog.WriteLoggerFactory(file=sink),
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,
)
slog = structlog.get_logger("bench")

logger.remove()                                  # 3. Loguru, serialized sink
logger.add(sink, serialize=True, level="INFO")

N = 50_000
cases = {
    "stdlib": (lambda: std.info("order_validated", extra={"order_id": "ORD-992"}),
               lambda: std.debug("noisy", extra={"order_id": "ORD-992"})),
    "structlog": (lambda: slog.info("order_validated", order_id="ORD-992"),
                  lambda: slog.debug("noisy", order_id="ORD-992")),
    "loguru": (lambda: logger.bind(order_id="ORD-992").info("order_validated"),
               lambda: logger.bind(order_id="ORD-992").debug("noisy")),
}
for name, (emitted, suppressed) in cases.items():
    on = timeit.timeit(emitted, number=N) / N * 1e6
    off = timeit.timeit(suppressed, number=N) / N * 1e6
    print(f"{name:>9}  emitted {on:6.1f} us   below threshold {off:5.2f} us")

Expected Output:

   stdlib  emitted    6.9 us   below threshold  0.42 us
structlog  emitted    7.6 us   below threshold  0.09 us
   loguru  emitted   14.3 us   below threshold  1.10 us

Absolute numbers vary with CPU, payload size, and Python build, so treat the shape of the result rather than the digits as the finding: the three libraries land within one order of magnitude when they actually emit, and the gap between them is smaller than the gap between emitting and not emitting. structlog's filtering bound logger is the cheapest suppression path because it never builds the event dict at all, while Loguru still constructs part of its record before the sink level rejects it.

The practical guidance is to optimize the right thing. Drop events below your threshold before serialization, never log inside tight inner loops, and push I/O onto a background worker. Once those three rules hold, the choice of library has a negligible effect on throughput, and you should decide on output shape, context model, and ecosystem fit instead. If you do benchmark, measure realistic payloads with JSON rendering enabled rather than empty messages, because empty-message microbenchmarks flatter the standard library in a way that does not survive contact with production.

Per-call cost, emitted versus suppressed Left panel, microseconds per emitted call against a null sink: standard library 6.9, structlog 7.6, Loguru 14.3. Right panel, microseconds per call for events below the threshold: standard library 0.42, structlog 0.09, Loguru 1.10. Note the two panels use different scales. event emitted (µs / call) stdlib 6.9 structlog 7.6 loguru 14.3 0 5 10 15 below threshold (µs / call) stdlib 0.42 structlog 0.09 loguru 1.10 0 0.5 1.0
Measured against a null sink so the numbers isolate library cost from disk latency. The panels use different scales: suppression is roughly an order of magnitude cheaper than emission, and structlog's filtering bound logger never builds the event dict at all.

Async & concurrency considerations

All three libraries must avoid two failure modes under concurrency: blocking I/O on the request path, and context bleeding between concurrent tasks.

For context isolation, both structlog and the standard library rely on contextvars, which is correct under asyncio because each task copies the context at creation — the mechanics are unpacked in context variables and thread safety. structlog exposes this directly through bind_contextvars and merge_contextvars, covered in depth in binding context variables in structlog. Loguru offers logger.contextualize(), a context manager that scopes bound fields, plus logger.bind() for a child logger. Thread-local storage, by contrast, leaks across await boundaries and should be avoided in async code. One subtlety catches teams moving work to a thread pool: loop.run_in_executor does not copy the context by default, so bound fields vanish inside the worker unless you pass a contextvars.copy_context().run wrapper or use asyncio.to_thread, which does copy.

For non-blocking I/O, the standard library uses QueueHandler feeding a QueueListener on a background thread, so the request thread only does a fast in-memory enqueue — the pattern documented in non-blocking logging with QueueHandler. Loguru collapses this into a single flag: logger.add(sink, enqueue=True) spawns a worker and serializes off the hot path, which is also what makes it multiprocessing-safe, as explained in async logging with Loguru enqueue. structlog itself does no I/O; it hands the rendered line to a stdlib handler, so you compose it with QueueHandler to get the same async guarantee.

# Loguru: one flag makes the sink process-safe and non-blocking.
from loguru import logger
logger.add("app.log", enqueue=True, serialize=True, rotation="100 MB")

Expected Output:

(no console output; structured records are written asynchronously to app.log,
 rotated at 100 MB, with the calling thread never blocking on disk I/O)

Both async paths need an explicit drain at shutdown or the last records are lost. With Loguru, await logger.complete() flushes coroutine sinks and waits for the enqueued worker; with the stdlib, call listener.stop() in a shutdown hook; structlog inherits whichever of those its underlying handler uses. structlog also offers await log.ainfo(...) and its siblings, which run the processor chain in a worker thread so a slow renderer cannot stall the event loop — worth using only when your chain does real work, since the thread hop costs more than a short chain does.

Under a forking server such as gunicorn, the ordering rule differs per library. Loguru's enqueue=True uses a multiprocessing-safe queue and tolerates forks, but a sink created before fork() and then written from several workers without enqueue will interleave partial lines. structlog's configuration is plain Python state and survives a fork intact, though any QueueListener thread beneath it does not — start it in a post_fork hook. The same constraints apply to multiprocessing pools, covered in thread-safe logging in multiprocessing.

Two concurrent requests through one non-blocking write path Task A and Task B each bind their own request id into a copied context, so their fields never mix. Both call the logging front end, which renders and enqueues without blocking; a background writer thread drains the queue to one file. A dashed message at the bottom marks the shutdown drain that must run or the last records are lost. Task A req-1 Task B req-2 front end structlog / Loguru queue enqueue / QueueHandler writer background thread log.info() with req-1 bound log.info() with req-2 bound render, then enqueue and return worker drains the queue app.log shutdown drain: logger.complete() or listener.stop() each task copies the context when it is created so req-1 and req-2 fields can never bleed into each other skip the drain and the last records are lost use asyncio.to_thread, not a bare run_in_executor
Context is copied per task, so two in-flight requests keep their own fields; rendering hands off to a queue immediately, and only the background worker touches the file — provided something drains it at shutdown.

Production code examples

A realistic decision is rarely "library X for everything." The most robust production pattern uses structlog as the front end while routing everything, including third-party stdlib loggers, through one chain. This gives you flat JSON, async safety, and full capture of library logs.

import logging
import structlog

# Shared processors run on BOTH structlog and stdlib log records.
shared = [
    structlog.contextvars.merge_contextvars,
    structlog.processors.add_log_level,
    structlog.processors.TimeStamper(fmt="iso"),
]

structlog.configure(
    processors=shared + [
        # Hand off to ProcessorFormatter for final rendering via stdlib.
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    logger_factory=structlog.stdlib.LoggerFactory(),
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,
)

formatter = structlog.stdlib.ProcessorFormatter(
    foreign_pre_chain=shared,                 # for logs NOT from structlog
    processors=[
        structlog.stdlib.ProcessorFormatter.remove_processors_meta,
        structlog.processors.JSONRenderer(),  # final flat JSON
    ],
)

handler = logging.StreamHandler()
handler.setFormatter(formatter)
root = logging.getLogger()
root.addHandler(handler)
root.setLevel(logging.INFO)

# Your code uses structlog; a third-party library uses stdlib logging.
structlog.get_logger("orders").info("order_validated", order_id="ORD-992")
logging.getLogger("sqlalchemy.engine").info("connection_opened")

Expected Output:

{"order_id": "ORD-992", "event": "order_validated", "level": "info", "timestamp": "2026-06-19T09:14:02.512Z"}
{"event": "connection_opened", "level": "info", "timestamp": "2026-06-19T09:14:02.514Z"}

Both lines are flat JSON, even though only the first originated from structlog. The mirror-image arrangement makes Loguru the owner and turns the stdlib tree into a feeder, using an InterceptHandler on the root logger. It is the right choice when you want Loguru's rotation and tracebacks but still need dependency logs in the same stream:

import logging
import sys

from loguru import logger

class InterceptHandler(logging.Handler):
    """Redirect every stdlib LogRecord into Loguru."""

    def emit(self, record: logging.LogRecord) -> None:
        try:
            level = logger.level(record.levelname).name
        except ValueError:
            level = record.levelno  # levels Loguru does not name, e.g. 25
        # Walk back past the logging module frames so the caller is reported.
        frame, depth = logging.currentframe(), 2
        while frame and frame.f_code.co_filename == logging.__file__:
            frame = frame.f_back
            depth += 1
        logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())

logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)
for name in ("uvicorn", "uvicorn.access", "sqlalchemy.engine"):
    lib = logging.getLogger(name)
    lib.handlers = []       # drop the library's own handler
    lib.propagate = True    # let the root InterceptHandler see the record

logger.remove()
logger.add(sys.stdout, serialize=True, enqueue=True, level="INFO", diagnose=False)

logger.bind(order_id="ORD-992").info("order_validated")
logging.getLogger("sqlalchemy.engine").info("connection_opened")

Expected Output:

{"text": "order_validated\n", "record": {"level": {"name": "INFO"}, "message": "order_validated", "extra": {"order_id": "ORD-992"}, "time": {"repr": "2026-06-19 09:14:02.512+00:00"}}}
{"text": "connection_opened\n", "record": {"level": {"name": "INFO"}, "message": "connection_opened", "extra": {}, "time": {"repr": "2026-06-19 09:14:02.514+00:00"}}}

Both records now flow through one sink with one level threshold, one rotation policy, and one shutdown drain. The cost, visible in the output, is the nested envelope — which is exactly the trade the flat custom sink from the step-by-step section reverses.

structlog as front end versus Loguru as owner On the left, application logs and dependency logs converge on ProcessorFormatter with a foreign pre-chain and leave as one flat JSON line. On the right, application logs go straight to the Loguru sink while dependency logs are pulled in by an InterceptHandler on the root logger, and the output carries Loguru's nested record envelope. structlog as the front end your code structlog.get_logger() dependency logging.getLogger() one chain ProcessorFormatter foreign_pre_chain = the shared processors one flat JSON line {"event": ..., "order_id": "ORD-992"} app and dependency share one schema Loguru as the owner your code loguru.logger dependency logging.getLogger() bypasses the stdlib InterceptHandler on the root logger one Loguru sink {"record": {"extra": {"order_id": "ORD-992"}}} nested envelope, so every query changes
Two workable arrangements, one rule: exactly one component owns the write. Either structlog renders both sources through ProcessorFormatter, or Loguru owns the sink and an InterceptHandler feeds the standard library tree into it.

When to pick each

Use the standard library when you are publishing a library that others import (never impose a logging dependency on consumers), when an operations team mandates dictConfig-driven configuration, or when adding a dependency is genuinely off the table. It is also the correct answer when the logging setup must be auditable as deployment configuration rather than code, and the production configuration checklist will get you most of the way. The migration path away from it later is well-trodden; see migrating from standard logging to structlog.

Use structlog when machine-readable, flat JSON is the product of your logging, when you need rigorous per-request context binding under async or threads, and when you want one rendering pipeline for both your code and your dependencies. It is the default recommendation for new microservices and the basis for Loguru vs structlog for microservices. It is also the easiest of the three to extend with a trace-correlation processor, which matters if the logs sit alongside distributed tracing with OpenTelemetry.

Use Loguru when developer velocity dominates, when you want rotation, retention, and rich tracebacks without assembling handlers, and when a single non-blocking sink via enqueue=True covers your needs. It shines in CLIs, batch jobs, and small services where nobody is going to maintain a processor chain. For a concrete framework-level decision that weighs all three in an async web context, continue to choosing a logging library for FastAPI, and for the Django equivalent see structlog JSON logging in Django.

Whatever you choose, choose once per service and write it down. The expensive outcome is not picking the "wrong" library — all three can be made to emit correct, correlated, non-blocking JSON — but running two of them side by side with different schemas, so half your fields are queryable and half are not.

Which logging library to choose Three questions asked in order. Yes to publishing an importable library or a mandated dictConfig setup selects the standard library. Yes to flat queryable JSON with per-request context selects structlog. Yes to rotation, retention, and rich tracebacks out of the box selects Loguru. A closing note warns against running two write paths. Shipping an importable library? or an ops team mandates dictConfig or a new dependency is off the table yes standard library logging zero deps, dictConfig, a custom Formatter no Is flat, queryable JSON the product? per-request context under async or threads one pipeline for your logs and your deps yes structlog processor chain, contextvars, flat JSON no Is developer velocity the constraint? rotation, retention, rich tracebacks, no wiring a CLI, a batch job, or a small service yes Loguru sinks, enqueue=True, rotation built in no Still tied? structlog for services, Loguru for CLIs and batch jobs — and never run two write paths at once.
Answer the questions in order and stop at the first yes. The bottom rule matters more than the branch you land on: one service, one owner of the write path.

Common mistakes

  • Error signature: nested fields like record.extra.order_id break your log queries and dashboards. Root cause: using Loguru's serialize=True while assuming a flat schema. Remediation: either query the nested path your store actually receives, or attach a custom serializing sink that lifts record["extra"] to the top level before json.dumps, as in step 4 above.
  • Error signature: third-party library logs are missing from your JSON stream. Root cause: configuring structlog or Loguru for your own loggers but never bridging the stdlib root logger. Remediation: add ProcessorFormatter with a foreign_pre_chain (structlog) or an InterceptHandler (Loguru) so foreign records flow through the same renderer.
  • Error signature: every line appears twice, once as JSON and once as coloured text. Root cause: Loguru's default stderr sink was never removed, or a stdlib handler was added while propagate was still True on a child logger. Remediation: call logger.remove() before your first logger.add(), and keep exactly one handler at the root of the stdlib tree.
  • Error signature: context fields appear on the wrong concurrent request. Root cause: storing per-request state in module globals or thread-locals inside async handlers, or hopping to an executor that did not copy the context. Remediation: use contextvars-based binding (bind_contextvars or logger.contextualize) and prefer asyncio.to_thread over a bare run_in_executor.
  • Error signature: throughput collapses under load with synchronous file or network sinks. Root cause: writing to disk or a socket on the request path. Remediation: enable enqueue=True in Loguru, or wrap stdlib handlers in QueueHandler/QueueListener, so serialization and I/O happen on a background worker — and drain it at shutdown.
  • Error signature: production tracebacks contain database passwords or customer records. Root cause: Loguru's diagnose=True default renders local variable values inside exception frames. Remediation: set diagnose=False on every deployed sink and keep the rich rendering for local development only.

Frequently Asked Questions

Which logging library is fastest in Python?

Standard library logging with a tuned formatter is fastest for trivial messages, but structlog with cache_logger_on_first_use enabled and a filtering bound logger is competitive and emits structured output directly. Loguru carries slightly higher per-call cost because of its rich record model and caller introspection, though enqueue=True moves serialization and I/O off the hot path.

Can I use structlog and the standard library logging module together?

Yes. structlog is designed to wrap standard library logging through ProcessorFormatter and LoggerFactory, so third-party libraries that log via the stdlib still flow through your structlog processor chain and render as JSON.

Does Loguru support structured JSON logging?

Yes. Calling logger.add with serialize=True makes Loguru emit one JSON object per line, including the message, level, timestamp, and any keyword bindings added with logger.bind. The fields you bind land under record.extra rather than at the top level, so add a custom sink if your store needs a flat schema.

When should I avoid third-party logging libraries entirely?

Stick with the standard library when you ship a library others import, when your platform mandates dictConfig-driven configuration, or when you cannot add dependencies. Applications and services almost always benefit from structlog or Loguru.

Can structlog and Loguru run in the same process?

They can, but only one of them should own the write path. Pick a front end, then bridge the other side into it: route stdlib records through structlog's ProcessorFormatter, or install a Loguru InterceptHandler on the root logger. Running two independent sinks produces duplicated lines with two different schemas.

Is it worth migrating an existing service off the standard library?

Migrate when your logs are being parsed by machines and the parsing is fragile, or when per-request correlation is missing. The cutover is incremental because both third-party options can sit in front of the existing handler tree, so you do not have to rewrite call sites in one change.