- stdlib logging is 2x faster on raw throughput but becomes painful for structured logging without custom formatters.
- loguru offers the cleanest API with automatic exception context capture (locals at crash site), at the cost of 2x slower throughput that rarely matters in production.
- structlog excels at processor pipelines for PII masking and context binding, sitting between stdlib's verbosity and loguru's simplicity.
- Use loguru for applications, stdlib for libraries, and structlog when you need advanced filtering or enrichment pipelines.
The stdlib logging Module Wins on Raw Throughput
Python’s built-in logging module beats both loguru and structlog in pure write speed — but that’s almost never the metric that matters in production. I spent a weekend benchmarking all three libraries with realistic payloads (structured JSON, exception formatting, context injection) and the performance story is more nuanced than “stdlib = fast.”
The real question isn’t throughput. It’s how much time you spend wrestling with Logger.getLogger(__name__), formatters, handlers, and handler-level filters versus just writing logger.info("message", user_id=123) and shipping.

Benchmark Setup: What I Actually Measured
I tested on Python 3.11 with three scenarios:
- Simple string logging — 100k iterations of
logger.info("hello world") - Structured context — 100k iterations with 5 key-value pairs per log
- Exception formatting — 10k iterations with traceback serialization
All tests write to /dev/null to isolate library overhead from I/O. The goal is to measure the cost of each API, not disk speed.
import logging
import time
from loguru import logger as loguru_logger
import structlog
# stdlib logging setup
stdlib_logger = logging.getLogger(__name__)
stdlib_logger.setLevel(logging.INFO)
handler = logging.FileHandler("/dev/null")
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(message)s"
))
stdlib_logger.addHandler(handler)
# loguru setup
loguru_logger.remove() # Remove default stderr
loguru_logger.add("/dev/null", format="{time} {level} {message}")
# structlog setup
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(file=open("/dev/null", "w")),
cache_logger_on_first_use=True,
)
struct_logger = structlog.get_logger()
# Simple string test
start = time.perf_counter()
for _ in range(100_000):
stdlib_logger.info("hello world")
stdlib_time = time.perf_counter() - start
start = time.perf_counter()
for _ in range(100_000):
loguru_logger.info("hello world")
loguru_time = time.perf_counter() - start
start = time.perf_counter()
for _ in range(100_000):
struct_logger.info("hello world")
structlog_time = time.perf_counter() - start
print(f"stdlib: {stdlib_time:.3f}s")
print(f"loguru: {loguru_time:.3f}s")
print(f"structlog: {structlog_time:.3f}s")
Results on my M1 MacBook:
stdlib: 0.142s
loguru: 0.287s (2.0x slower)
structlog: 0.203s (1.4x slower)
Stdlib wins on raw throughput. But this benchmark is misleading — nobody logs plain strings in production.
Structured Logging: Where loguru and structlog Shine
The moment you add structured context, the stdlib API becomes painful. You have three bad options:
-
String formatting —
logger.info(f"User {user_id} purchased {item}")
Loses structure. Can’t query byuser_idin your log aggregator. -
extradict —logger.info("purchase", extra={"user_id": 123})
Requires custom formatter. Collides with logger internals if you use keys likenameorlevelname. -
LoggerAdapter — Wrap your logger to inject context.
Boilerplate hell. You end up writing a mini-framework.
Here’s what the same structured log looks like across all three:
# stdlib (using extra)
stdlib_logger.info(
"user purchase",
extra={"user_id": 123, "item": "widget", "price": 9.99}
)
# loguru
loguru_logger.info(
"user purchase",
user_id=123, item="widget", price=9.99
)
# structlog
struct_logger.info(
"user purchase",
user_id=123, item="widget", price=9.99
)
The loguru and structlog APIs are identical here. Both serialize to JSON by default. The stdlib approach requires you to write a custom JsonFormatter or pull in a third-party formatter like python-json-logger.
Performance with structured context (100k iterations, 5 keys each):
stdlib: 0.198s
loguru: 0.351s (1.8x slower)
structlog: 0.264s (1.3x slower)
structlog pulls ahead of loguru here because it’s designed around processor pipelines — JSON rendering is a single optimized step. loguru does more work per log call (frame inspection for automatic context, rich formatting).
Exception Formatting: loguru’s Killer Feature
When you log exceptions, loguru automatically captures locals, colorizes tracebacks, and formats them in a way that’s actually readable. The stdlib gives you logger.exception(msg) which dumps a plain traceback. structlog punts this to your processor chain.
Here’s a contrived division-by-zero:
def divide(a, b):
numerator = a # Local variable for demo
denominator = b
return numerator / denominator
try:
divide(10, 0)
except ZeroDivisionError:
# stdlib
stdlib_logger.exception("division failed")
# loguru (with diagnose=True)
loguru_logger.exception("division failed")
# structlog
struct_logger.exception("division failed")
loguru output includes the values of numerator and denominator at the crash site. This is game-changing for production debugging — you don’t need to reproduce the bug to see what inputs caused it.
The cost? Exception logging is 2-3x slower on loguru because it walks the frame stack and serializes locals. But when you’re logging exceptions, you’re already in a failure path. The extra 5ms doesn’t matter.
API Ergonomics: Why I Pick loguru for New Projects
The stdlib logging module makes you think about:
- Logger hierarchy (
__name__vs"root"vs custom names) - Handler propagation (
propagate=Falseto avoid duplicate logs) - Formatter vs Filter vs Handler-level filtering
- Thread safety when adding handlers dynamically
loguru collapses all of this into a single global logger object. You configure once at startup:
from loguru import logger
logger.remove() # Clear default handler
logger.add(
"app.log",
rotation="500 MB",
retention="10 days",
compression="zip",
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}",
serialize=True # JSON output
)
That’s it. No getLogger, no handler management, no formatter boilerplate. The tradeoff is you lose fine-grained control over logger namespaces — but in practice, I’ve never needed that.
structlog sits in the middle. It gives you structured logging with processor pipelines (powerful for filtering/enrichment) but requires more upfront config than loguru:
import structlog
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer()
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
The processor list is explicit, which means you can insert custom processors (e.g., PII masking, correlation IDs). But it’s also verbose. If you just want “good defaults,” loguru is cleaner.
Context Binding: structlog’s Strength
structlog’s bind() API is the best way to attach per-request or per-task context:
log = structlog.get_logger()
log = log.bind(request_id="abc-123", user_id=456)
log.info("processing payment") # Includes request_id and user_id
log.info("payment successful") # Still includes them
loguru has logger.bind() too, but it returns a new logger instance. You have to pass that logger around explicitly or use contextvars to make it work with async code. structlog plays nicer with contextvars out of the box.
The stdlib approach is LoggerAdapter, which is clunky:
adapter = logging.LoggerAdapter(stdlib_logger, {"request_id": "abc-123"})
adapter.info("processing payment")
You lose type hints and IDE autocomplete because LoggerAdapter isn’t a real Logger. It’s a wrapper.

When stdlib Logging Still Wins
If you’re writing a library (not an application), stick with stdlib logging. Why? Because:
- No dependencies — Your users can integrate your logs into their logging config without pulling in loguru/structlog.
- Standard logger hierarchy — Users can silence your logs with
logging.getLogger("your_lib").setLevel(logging.WARNING). - Handler flexibility — Users might route logs to Sentry, CloudWatch, or a custom handler. stdlib makes that trivial.
loguru and structlog are opinionated. They assume you control the logging setup. In a library, that’s not true.
Memory Overhead: A Surprise
I profiled memory usage with tracemalloc after logging 1M messages:
import tracemalloc
tracemalloc.start()
# ... log 1M messages
current, peak = tracemalloc.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.1f} MB")
print(f"Peak: {peak / 1024 / 1024:.1f} MB")
tracemalloc.stop()
Results (after logging, before clearing handlers):
- stdlib: 12.3 MB peak
- loguru: 18.7 MB peak
- structlog: 14.1 MB peak
loguru allocates more because it keeps frame info and does lazy formatting. If you’re running on a memory-constrained device (Raspberry Pi, Lambda cold starts), this might matter. For most apps, it’s noise.
Async Logging: All Three Struggle
None of these libraries are truly async-native. They all block on I/O when writing logs. If you’re logging to disk or a network handler, you’ll stall your event loop.
The workaround is to use a queue-based handler:
import logging.handlers
queue_handler = logging.handlers.QueueHandler(queue.Queue())
queue_listener = logging.handlers.QueueListener(
queue_handler.queue,
logging.FileHandler("app.log")
)
queue_listener.start()
loguru has enqueue=True for this:
logger.add("app.log", enqueue=True) # Writes happen in a background thread
structlog doesn’t have a built-in solution. You’d wrap the logger factory with a queue yourself.
But even with queued logging, you’re doing synchronous work (formatting, serialization) in your async code. If you log on every request in a high-throughput FastAPI app, it adds up. My FastAPI vs Flask async post touched on this — logging was responsible for 12% of request latency.
Configuration Hell vs Convention
The stdlib logging module has too many ways to configure itself:
logging.basicConfig()— Simple, but limited. Can only be called once.logging.config.dictConfig()— YAML/JSON config. Powerful but verbose.- Programmatic setup —
getLogger(),addHandler(), etc. Flexible but error-prone.
I’ve debugged so many issues caused by multiple libraries calling basicConfig() or handlers being added twice. It’s not the library’s fault — it’s the API’s flexibility.
loguru sidesteps this by making the logger a singleton. You configure it once, globally. If a library wants to log, it either imports your logger or uses stdlib (which you can bridge with loguru.logger.add()‘s sink=logging.Handler trick — but I wouldn’t recommend it).
The Benchmark That Actually Matters
Forget throughput. The metric I care about is time to useful logs in production.
With stdlib:
- Write custom
JsonFormatteror installpython-json-logger - Set up handler config (file rotation, compression, levels)
- Debug why logs are duplicated (handler propagation)
- Add context with
LoggerAdapterorextra - Write custom exception formatter for better tracebacks
Time: 2-4 hours for a non-trivial setup.
With loguru:
logger.add("app.log", serialize=True, rotation="500 MB")
Time: 5 minutes.
That’s the real performance gap. And if you’re the kind of person who reaches for Dark Chocolate Espresso Beans at 2am to debug logging config, you’ll appreciate loguru’s simplicity.
Edge Case: Logger Pickleability
Stdlib loggers are pickleable. loguru’s global logger is not. This breaks multiprocessing if you try to pass a loguru logger to a worker process:
from multiprocessing import Pool
from loguru import logger
def worker(x):
logger.info(f"processing {x}") # Fails with PickleError
return x * 2
with Pool(4) as pool:
pool.map(worker, range(10))
The fix is to re-initialize the logger in each worker:
def init_worker():
logger.remove()
logger.add("worker.log")
with Pool(4, initializer=init_worker) as pool:
pool.map(worker, range(10))
This is annoying but not a dealbreaker. stdlib doesn’t have this issue because loggers are just dict lookups by name.
FAQ
Q: Can I use loguru in a library?
No. Libraries should use stdlib logging so users can integrate logs into their own config. loguru is for applications where you control the logging setup.
Q: Is structlog faster than loguru for JSON serialization?
Yes, by about 25% in my benchmarks. structlog’s processor pipeline is more optimized for structured output. But loguru’s frame inspection and rich formatting add features you can’t get from structlog without custom processors.
Q: Should I care about logging performance?
Only if you’re logging in a hot path (e.g., every request in a 10k RPS API). In that case, use async queue-based logging and consider sampling (log 1% of requests). For most apps, ergonomics matter more than throughput.
My Actual Recommendation
Use loguru for new applications. The API is clean, the defaults are sane, and the exception formatting alone saves hours of debugging. The 2x throughput penalty is irrelevant unless you’re logging millions of messages per second (and if you are, you have bigger problems).
Use stdlib logging for libraries or when you need fine-grained logger hierarchy control.
Use structlog if you need processor pipelines for PII masking, correlation IDs, or complex filtering logic. It’s the most powerful but also the most verbose.
I’m still not entirely sure why the Python community hasn’t standardized on a better default. The stdlib logging module is 20+ years old and shows its age. Maybe PEP 282 (which introduced it) made sense in 2003, but in 2026 we have better options.
One thing I haven’t tested: how these libraries behave under backpressure when logging to a slow sink (network handler, rate-limited API). My guess is they all block, but I’d love to see benchmarks with realistic I/O latency. If you’ve run those tests, let me know — I’m curious.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,839 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (956 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (792 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (754 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (579 views)