Dash0 Raises $110M Series B at $1B Valuation

Last updated: April 30, 2026

Choosing a Python Logging Library in 2026

Python's logging ecosystem has always looked different from most languages. Rather than leaving developers to choose between competing third-party options, it shipped a comprehensive logging module in the standard library which meant most Python code already uses logging at some level, and every third-party alternative builds on top of it, wraps around it, or deliberately replaces it.

But the standard library module has real friction points. Configuration is verbose, structured output requires extra work, and the API carries design decisions from the early 2000s that feel dated against modern expectations. That friction is why other libraries exist and continue to grow.

This guide covers what's worth considering in 2026: which libraries matter, how they compare, where they overlap with the standard module, and when each one makes sense.

1. The standard library logging module

The standard logging module is where most Python applications start. It ships with Python, every third-party framework and library emits logs through it, and the entire ecosystem of handlers, formatters, and integrations (including OpenTelemetry) is built around its interfaces.

Even if you end up adopting a different library for your application code, you'll still interact with the logging module because that's what your dependencies use under the hood.

In production, most Python applications configure logging through logging.config.dictConfig. If you've worked with Django, you'll recognize the pattern: a LOGGING dictionary in your settings that declares formatters, handlers, and logger routing in one place. The same approach works in any Python application.

Here's a minimal configuration that logs JSON to stdout:

python
12345678910111213141516171819202122232425262728293031
import logging.config
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": (
"pythonjsonlogger"
".json.JsonFormatter"
),
"format": (
"%(asctime)s %(name)s"
" %(levelname)s %(message)s"
),
},
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"formatter": "json",
"stream": "ext://sys.stdout",
},
},
"root": {
"level": "INFO",
"handlers": ["stdout"],
},
}
logging.config.dictConfig(LOGGING)

This pairs the logging module with python-json-logger for structured JSON output. The dictConfig approach keeps logging configuration declarative, separate from application code, and easy to override per environment.

Beyond the basics, the standard module's handler ecosystem is where its flexibility shows. QueueHandler and QueueListener push logging off the main thread for latency-sensitive paths, while MemoryHandler gives you ring-buffer behavior, accumulating records in memory and only flushing when a threshold severity is hit, so you capture the debug context leading up to an error without paying I/O cost on every call. And the Filter interface lets you selectively suppress, modify, or route records in flight before they reach any handler.

The standard module also provides the cleanest path into OpenTelemetry-native logs. The opentelemetry-instrumentation-logging package hooks directly into the standard library's logging infrastructure, injecting trace and span IDs into every log record automatically. Because the OpenTelemetry Python SDK was designed around logging.Handler, you can route your log records through an OpenTelemetry pipeline with minimal integration code:

python
123456789101112131415161718192021222324252627282930
from opentelemetry.instrumentation.logging import (
LoggingInstrumentor,
)
# Injects trace_id, span_id, and
# resource.service.name into log records
LoggingInstrumentor().instrument(
set_logging_format=True,
)
# Or attach the OTel handler directly
import logging
from opentelemetry.sdk._logs import LoggingHandler
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import (
BatchLogRecordProcessor,
)
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter,
)
logger_provider = LoggerProvider()
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter())
)
handler = LoggingHandler(
logger_provider=logger_provider
)
logging.getLogger().addHandler(handler)

When you pair this with active spans in your application, the resulting OTel log records carry matching trace and span IDs. Your observability backend can then correlate logs with the traces they belong to automatically.

Where logging falls short

Even with dictConfig handling the setup, the standard module demands more upfront ceremony than most alternatives. You need to understand the relationship between loggers, handlers, formatters, and filters before you can configure anything non-trivial, and small mistakes (a missing disable_existing_loggers: False, a handler attached to the wrong logger name) can silently swallow log output in ways that are hard to debug.

Per-request context propagation is possible through contextvars combined with a custom Filter that injects fields into every log record automatically (our Python logging guide covers this pattern in detail). It works, but it requires you to write and wire up the filter yourself while libraries like structlog and Loguru provide this out of the box with less ceremony.

2. structlog

structlog is built around one idea: every log entry is a dictionary that passes through a chain of processors before it reaches any output:

python
1234567891011121314151617181920
import logging
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(
fmt="iso"
),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
logging.INFO
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
)

Each processor is a callable that receives the logger, the method name, and the event dictionary, and returns a modified event dictionary (or raises DropEvent to suppress the record). This makes it easy to build custom processing logic like scrubbing sensitive data, sampling, enrichment, or conditional routing:

python
12345
def redact_sensitive_fields(logger, method, event_dict):
for key in ("password", "token", "api_key"):
if key in event_dict:
event_dict[key] = "[REDACTED]"
return event_dict

Context management in structlog uses Python's contextvars module, which means it works correctly across asyncio tasks and thread boundaries without manual propagation. You bind context at the start of a request and it flows through your entire call stack automatically:

python
123456789101112131415
import structlog
structlog.contextvars.bind_contextvars(
request_id="abc-123",
user_id="user-456",
)
# All subsequent log calls in this context
# will include request_id and user_id
log.info("order_created", order_id="ord-789")
# Clean up when the request ends
structlog.contextvars.unbind_contextvars(
"request_id", "user_id"
)

structlog also integrates tightly with the standard library. You can configure it to use logging as its output backend, which means all of logging's handler ecosystem (file rotation, syslog, queues, the OpenTelemetry handler) is available to structlog without any additional adapters:

python
123456789101112131415161718
import logging
import structlog
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(
fmt="iso"
),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
)

This dual-mode operation is structlog's biggest strength for production systems. You get structlog's ergonomic API and processor pipeline for your application code, with the standard library's mature handler infrastructure for output routing. It also means structlog inherits OpenTelemetry support "for free" through the standard library integration.

The main downside is the learning curve. structlog's documentation is thorough, but the concepts (bound loggers, processor chains, wrapper classes, logger factories) take time to internalize. The initial configuration can feel overwhelming compared to Loguru's single add() call, and getting the processor chain right for your specific needs requires understanding how all the pieces fit together.

3. Loguru

Loguru is the most popular third-party Python logging library on GitHub, with over 21,000 stars, and its popularity comes from a simple proposition: it removes almost all of the configuration boilerplate that the standard module demands.

You only need to import a pre-configured logger object and start writing log statements immediately:

python
12345678
from loguru import logger
logger.info(
"Request processed",
method="GET",
status=200,
latency_ms=47,
)

There's no handler setup, no formatter configuration, no getLogger(__name__) pattern. By default, Loguru writes colored, human-readable output to stderr, which is the right default for development. But when you need to change the output destination, format, or filtering behavior, the entire configuration API is a single add() function:

python
123456789101112
from loguru import logger
import sys
# Remove default stderr handler
logger.remove()
# JSON output to stdout for production
logger.add(
sys.stdout,
serialize=True, # enables JSON output
level="INFO",
)

The serialize=True flag converts every log record to JSON before sending it to the configured destination. This means you can get structured output without writing a custom formatter or installing an extra package.

Exception handling is another area where Loguru shines. The @logger.catch decorator wraps a function and logs the full traceback with local variable values when an exception occurs, while logger.exception() method does the same thing inline:

python
123456
@logger.catch
def process_order(order_id: str):
# If this raises, Loguru logs the full
# traceback with variable values
order = db.get_order(order_id)
return order.process()

Loguru also provides bind() for attaching contextual fields to a logger instance, and contextualize() as a context manager for scoped context that cleans itself up:

python
12345
with logger.contextualize(request_id="abc-123"):
logger.info("Processing started")
do_work()
logger.info("Processing complete")
# request_id is removed from context here

For projects that already use the standard logging module, Loguru provides an InterceptHandler pattern that routes all standard library log records through Loguru's pipeline, so you can adopt it incrementally without rewriting existing code:

python
123456789101112131415
import logging
from loguru import logger
class InterceptHandler(logging.Handler):
def emit(self, record):
level = logger.level(record.levelname).name
logger.opt(
depth=6, exception=record.exc_info
).log(level, record.getMessage())
logging.basicConfig(
handlers=[InterceptHandler()],
level=0,
force=True,
)

The main tradeoff with Loguru is that it uses a single global logger object, which means configuration is process-wide. In applications where different components need different logging behavior, you have to rely on a different mechanism rather than the named logger hierarchy that the standard module provides.

Loguru also doesn't have native OpenTelemetry integration at the time of writing. If you just need trace and span IDs in your log output for correlation, Loguru's official recipes show how to use a patcher function that reads the current span context and injects the IDs into the log record.

But if you want to export logs as OTel-native signals over the OpenTelemetry Protocol (OTLP), you'll need to route Loguru through the standard library's InterceptHandler so that log records reach the OTel LoggingHandler. That's an extra layer of indirection that the standard module and structlog (via its stdlib backend) don't require.

4. Logbook

Logbook was created by Armin Ronacher (the author of Flask) and Georg Brandl as a ground-up replacement for the standard logging module. It has around 1.5k GitHub stars and is still actively maintained, with recent releases supporting Python 3.13 and 3.14.

python
1234567
from logbook import Logger, StreamHandler
import sys
StreamHandler(sys.stdout).push_application()
log = Logger("my-app")
log.info("Hello, World!")

Logbook's distinguishing idea is a context-sensitive handler stack: instead of attaching handlers permanently to named loggers, you push and pop them scoped to a thread or with block.

Each handler has a bubble flag that controls whether records continue down the stack, and a NullHandler blackhole lets you build isolated logging environments without touching global config. The Processor stack injects context into records the same way, and a redirect_logging() compatibility layer routes standard library logs through Logbook's pipeline.

The tradeoffs limit its appeal for new projects, though. There's no built-in structured JSON output, so you'd have to write your own formatter. There's no OpenTelemetry integration, and because Logbook uses its own handler infrastructure, the OTel LoggingHandler can't attach without routing back through the stdlib compatibility layer.

structlog's contextvars-based approach has since solved the same per-request scoping problem in a way that works natively with asyncio. If your codebase uses Logbook today, it's worth planning a migration to structlog or the standard module, since the lack of structured output and OpenTelemetry support makes it harder to connect your logs to the rest of your telemetry pipeline.

5. picologging

picologging is a Microsoft-backed project that reimplements the standard library's logging module in C for raw speed. The goal is a drop-in replacement that runs 4 to 17 times faster without requiring any code changes:

python
123456789
import picologging as logging
logging.basicConfig()
logger = logging.getLogger()
logger.info("A log message!")
logger.warning(
"A log message with %s", "arguments"
)

A drop-in replacement that runs 4-17x faster would solve a real problem, but the project has stalled. The last PyPI release (v0.9.3) was in September 2023, and the repository has seen minimal activity since. It never left beta, and not every feature of the standard module is implemented. Python 3.13 and 3.14 aren't supported in released builds.

It's mentioned here because it still appears in comparison articles and search results, and the approach itself remains interesting. If the project resumes development, it could become a meaningful option for applications where logging throughput is a genuine bottleneck. But as of 2026, it's not something you should depend on for production use.

Integration with frameworks

Most Python web applications run inside Django, FastAPI, or Flask, and each framework has its own relationship with logging that affects which library fits best.

Django has the deepest stdlib integration of any Python framework. Its LOGGING dictionary in settings.py maps directly to dictConfig, and every Django component (ORM queries, request handling, template rendering) emits logs through the standard module, making it the path of least resistance.

For structlog, the django-structlog package adds a middleware that automatically binds request_id and user_id to every log entry for the duration of a request, with support for Django REST Framework, django-ninja, and Celery. Loguru doesn't have a dedicated Django package, so you'd need to wire up the InterceptHandler to capture Django's stdlib logs and route them through Loguru's pipeline.

FastAPI (and Starlette) is where structlog's contextvars integration pays off most directly. You write an ASGI middleware that calls clear_contextvars() at the start of each request, binds a request ID, path, and method, and every log call in your endpoint handlers automatically includes that context.

There's one gotcha worth knowing: Starlette's BaseHTTPMiddleware runs the middleware and the route handler in separate async contexts, so context variables bound inside a dependency won't be visible back in the middleware's response logging. The structlog docs cover this in detail. Loguru's contextualize() context manager works similarly but doesn't integrate with FastAPI's dependency injection the same way.

Flask works with all three libraries without much friction. structlog's documentation includes a Flask example that clears and binds context variables in a before_request handler. Django aside, Flask is the framework where the library choice matters least because Flask's own logging setup is minimal.

For Celery task logging, structlog can wrap Celery's task logger with structlog.wrap_logger() and bind task metadata through Celery's task_prerun signal. The django-structlog package handles this automatically if you're in a Django project.

Performance benchmarks

Python logging performance is rarely the bottleneck in a real application. Network I/O, database queries, and serialization overhead dominate most request lifecycles by orders of magnitude. That said, if you're logging in a tight loop or processing events at very high throughput, the differences between libraries do become measurable.

To get a sense of relative performance, I benchmarked the three libraries on Python 3.14 using pytest-benchmark across three scenarios: a simple log message, a message with 10 contextual fields, and exception logging. All three were configured with a null sink that still performs full JSON serialization, so the numbers reflect record creation, field processing, and serialization overhead, not I/O.

LibrarySimple message+ 10 context fieldsException loggingThroughput (ops/s, 10 fields)
structlog2.79 µs4.05 µs23.70 µs242k
stdlib + json5.03 µs7.04 µs29.94 µs139k
Loguru5.55 µs6.76 µs37.97 µs147k

structlog is roughly 2× faster than stdlib and Loguru for simple messages. The advantage comes from its lighter processing path: it builds a plain dictionary rather than a full LogRecord object, avoids the threading lock that stdlib acquires on every handler dispatch, and caches its processor chain after the first call.

The ordering shifts in the context fields scenario. Loguru's kwargs land directly in the record's extra dict, while stdlib merges the extra argument into LogRecord.__dict__ on every call, so Loguru edges ahead of stdlib as the number of fields grows.

A significant chunk of stdlib's overhead comes from Python's built-in json module rather than from logging itself. Swapping the JSON encoder in your formatter closes most of the gap with structlog:

Scenariostdlib (json)stdlib + orjsonstdlib + msgspec
Simple message5.26 µs3.52 µs3.42 µs
+ 10 context fields7.72 µs5.42 µs5.05 µs
Exception logging31.91 µs28.09 µs27.62 µs
Throughput (10 fields)130k ops/s185k ops/s198k ops/s

With msgspec, stdlib's throughput jumps from 139k to 198k ops/s, which brings it closer to structlog for the context fields scenario. If your team is already on stdlib and performance is the main reason you're considering a switch, try a faster serializer first.

The Throughput column tells the practical story. At 139k+ ops/s in the slowest configuration, all three libraries can handle far more log calls per second than most applications will ever produce. You'd need to be logging hundreds of thousands of times per second before the differences show up in a flame graph.

For the vast majority of Python applications, any of these libraries is fast enough. If profiling shows logging as a bottleneck, the first thing to check is whether you're logging too much at too high a frequency, not which library you're using.

Picking the right Python logging library

Most applications should start with the standard logging module and a JSON formatter like python-json-logger. It's not the most ergonomic API, but it works everywhere: every framework and third-party library emits through it, and the OTel SDK's LoggingHandler attaches directly to the root logger with no extra wiring.

If you find the standard module's configuration boilerplate painful or need more performance, structlog is the recommended upgrade for most teams. It's the fastest of the three, its processor chain gives you a clean model for redaction, enrichment, and sampling, and contextvars-based context propagation works correctly across asyncio boundaries without extra setup. You can also configure it to use the standard library as its output backend, which means you keep the entire logging handler ecosystem while writing against a much better API day to day.

Loguru is a reasonable choice if your team values minimal setup above everything else. A couple of lines get you structured JSON output, and file rotation, compression, and exception formatting come built in. The InterceptHandler pattern captures logs from third-party libraries that emit through the standard module, so you don't lose visibility into your dependencies.

The main tradeoffs are that its single global logger doesn't give you the per-component control of structlog's bound loggers or the standard module's named logger hierarchy, and reaching the OTel LoggingHandler requires routing through InterceptHandler rather than a native integration.

Final thoughts

The library matters less than the practices around it. Structured output, consistent context propagation, sensible log levels, and good field hygiene are what make logs useful in production. Get those right and any library on this list will serve you well.

If you're looking for an observability platform that's built around OpenTelemetry and treats logs, traces, and metrics as connected signals rather than separate tools, give Dash0 a try.

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah