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. The goal is a defensible decision, not a feature list.

Logging library comparison matrix Three vertical lanes for standard library logging, structlog, and Loguru, scored across configuration model, native structured output, and async handoff. dictConfig JSON out async I/O stdlib native manual QueueHandler structlog via stdlib native contextvars Loguru code-only serialize enqueue
How the three libraries score on configuration model, native structured output, and asynchronous handoff.

Prerequisites

Install all three so you can benchmark the same workload across them. Pin every dependency to a tested range:

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 comparison assumes Python 3.11 or newer, where contextvars is mature and exception groups render cleanly.

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. 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, 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. 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.

Step-by-step implementation

The clearest way to compare is to emit the same structured event from each library.

  1. Standard library. Build a JSON formatter and attach it to a handler. Structured fields ride in the extra dict.
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.
import logging
import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    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.
import sys
from loguru import logger

logger.remove()  # drop the default stderr sink
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"}}}

Note the shape difference: 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. That flatness matters when you index logs in Elasticsearch or Loki, and it is the single most common reason teams pick structlog for greenfield services.

Configuration reference

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)
Dependency footprint zero one pure-Python package one pure-Python package

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 in C. 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 integer 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.

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.

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. structlog exposes this directly through bind_contextvars and merge_contextvars, covered in depth in structlog architecture and setup. 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.

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. Loguru collapses this into a single flag: logger.add(sink, enqueue=True) spawns a worker process or thread and serializes off the hot path, which is also what makes it multiprocessing-safe. 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)

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. To achieve the same capture with Loguru you would install an InterceptHandler on the root logger that redirects stdlib records into Loguru, which works well but inverts the relationship: Loguru becomes the owner and the stdlib tree becomes a feeder.

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. 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.

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. For a concrete framework-level decision that weighs all three in an async web context, continue to choosing a logging library for FastAPI.

Common mistakes

Error signature: nested fields like record.extra.order_id breaking your log queries. Root cause: using Loguru's serialize=True and 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.

Error signature: third-party library logs 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: context fields appearing on the wrong concurrent request. Root cause: storing per-request state in module globals or thread-locals inside async handlers. Remediation: use contextvars-based binding (bind_contextvars or logger.contextualize) so each task gets an isolated copy of the context.

Error signature: throughput collapse under load with synchronous file sinks. Root cause: writing to disk or network 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.

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, though enqueue=True moves serialization 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.

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.