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:
12345678910111213141516171819202122232425262728293031import logging.configLOGGING = {"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:
123456789101112131415161718192021222324252627282930from opentelemetry.instrumentation.logging import (LoggingInstrumentor,)# Injects trace_id, span_id, and# resource.service.name into log recordsLoggingInstrumentor().instrument(set_logging_format=True,)# Or attach the OTel handler directlyimport loggingfrom opentelemetry.sdk._logs import LoggingHandlerfrom opentelemetry.sdk._logs import LoggerProviderfrom 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:
1234567891011121314151617181920import loggingimport structlogstructlog.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:
12345def 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:
123456789101112131415import structlogstructlog.contextvars.bind_contextvars(request_id="abc-123",user_id="user-456",)# All subsequent log calls in this context# will include request_id and user_idlog.info("order_created", order_id="ord-789")# Clean up when the request endsstructlog.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:
123456789101112131415161718import loggingimport structlogstructlog.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:
12345678from loguru import loggerlogger.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:
123456789101112from loguru import loggerimport sys# Remove default stderr handlerlogger.remove()# JSON output to stdout for productionlogger.add(sys.stdout,serialize=True, # enables JSON outputlevel="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:
123456@logger.catchdef process_order(order_id: str):# If this raises, Loguru logs the full# traceback with variable valuesorder = 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:
12345with 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:
123456789101112131415import loggingfrom loguru import loggerclass InterceptHandler(logging.Handler):def emit(self, record):level = logger.level(record.levelname).namelogger.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.
1234567from logbook import Logger, StreamHandlerimport sysStreamHandler(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:
123456789import picologging as logginglogging.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.
| Library | Simple message | + 10 context fields | Exception logging | Throughput (ops/s, 10 fields) |
|---|---|---|---|---|
| structlog | 2.79 µs | 4.05 µs | 23.70 µs | 242k |
| stdlib + json | 5.03 µs | 7.04 µs | 29.94 µs | 139k |
| Loguru | 5.55 µs | 6.76 µs | 37.97 µs | 147k |
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:
| Scenario | stdlib (json) | stdlib + orjson | stdlib + msgspec |
|---|---|---|---|
| Simple message | 5.26 µs | 3.52 µs | 3.42 µs |
| + 10 context fields | 7.72 µs | 5.42 µs | 5.05 µs |
| Exception logging | 31.91 µs | 28.09 µs | 27.62 µs |
| Throughput (10 fields) | 130k ops/s | 185k ops/s | 198k 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.
