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.
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.
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.
- Standard library. Build a JSON formatter and attach it to a handler. Structured fields ride in the
extradict, 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"}
- 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"}
- 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"}}}
- Compare the shapes, not the APIs. Loguru's
serialize=Truewraps your fields underrecord.extraand 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 fororder_id:"ORD-992"works against the flat shapes but must becomerecord.extra.order_idagainst 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 usingserialize=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"}
- 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_loggershort-circuits before any processor runs, and Loguru compares against the level of each sink. Drive all three from the sameLOG_LEVELenvironment 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 addsTRACEandSUCCESSlevels that have no stdlib equivalent.
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.
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.
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.
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.
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.
Common mistakes
- Error signature: nested fields like
record.extra.order_idbreak your log queries and dashboards. Root cause: using Loguru'sserialize=Truewhile assuming a flat schema. Remediation: either query the nested path your store actually receives, or attach a custom serializing sink that liftsrecord["extra"]to the top level beforejson.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
ProcessorFormatterwith aforeign_pre_chain(structlog) or anInterceptHandler(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
propagatewas stillTrueon a child logger. Remediation: calllogger.remove()before your firstlogger.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_contextvarsorlogger.contextualize) and preferasyncio.to_threadover a barerun_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=Truein Loguru, or wrap stdlib handlers inQueueHandler/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=Truedefault renders local variable values inside exception frames. Remediation: setdiagnose=Falseon every deployed sink and keep the rich rendering for local development only.
Related reading
- Modern Python Logging Libraries Deep Dive — the parent guide covering library architecture, configuration, and cost control.
- structlog Architecture and Setup — the processor pipeline and a production bootstrap configuration.
- Loguru Configuration and Sinks — sinks, rotation, retention, and enqueued writes in detail.
- Python Standard Library vs Third-Party Logging — the architectural trade-offs behind this comparison.
- Choosing a Logging Library for FastAPI — the same decision applied to an async web service.
- Benchmarking Python logging libraries — a harness that compares them fairly, and the ranking that does not survive concurrency.
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.