Benchmarking Python Logging Libraries
Published comparisons of Python logging libraries usually measure something other than the library: a console renderer against a JSON one, a null sink against a file, or a single-threaded loop that hides where each one locks. This page builds a harness that makes the comparison fair, and shows which conclusions survive it. It builds on structlog vs Loguru vs stdlib logging, part of the modern Python logging libraries deep dive section.
Prerequisites
pip install "structlog>=24.1.0,<26.0.0" \
"loguru>=0.7.2,<1.0.0" \
"python-json-logger>=2.0.7,<4.0.0" \
"pytest-benchmark>=4.0.0,<6.0.0"
export PYTHONHASHSEED=0
Pin the interpreter to production's version. All three libraries have hot paths that changed between recent minor releases.
Implementation
Step 1 — Configure all three to emit the same thing. Same fields, same order-independent JSON, same neutral sink.
# bench_libs.py
import io, json, logging, sys, time
from statistics import median
import structlog
from loguru import logger as loguru_logger
from pythonjsonlogger import jsonlogger
SINK = io.StringIO() # neutral: no I/O cost, same for all three
# --- stdlib -----------------------------------------------------------------
std_handler = logging.StreamHandler(SINK)
std_handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
std = logging.getLogger("bench.std")
std.addHandler(std_handler)
std.setLevel(logging.INFO)
logging.logProcesses = logging.logThreads = logging.logMultiprocessing = False
# --- structlog --------------------------------------------------------------
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(file=SINK),
cache_logger_on_first_use=True,
)
slog = structlog.get_logger("bench.structlog")
# --- loguru -----------------------------------------------------------------
loguru_logger.remove()
loguru_logger.add(SINK, serialize=True, level="INFO", enqueue=False,
backtrace=False, diagnose=False)
PrintLoggerFactory(file=SINK) and StreamHandler(SINK) matter: three libraries writing to three different destinations produce three measurements of those destinations.
Step 2 — Time each path. Median of three batches, warm-up first.
def ns_per_call(fn, n: int = 50_000) -> float:
fn()
out = []
for _ in range(3):
start = time.perf_counter_ns()
for _ in range(n):
fn()
out.append((time.perf_counter_ns() - start) / n)
return median(out)
order_id = 8812
CASES = {
"stdlib · disabled": lambda: std.debug("order accepted", extra={"order_id": order_id}),
"structlog· disabled": lambda: slog.debug("order accepted", order_id=order_id),
"loguru · disabled": lambda: loguru_logger.debug("order accepted {}", order_id),
"stdlib · emitted": lambda: std.info("order accepted", extra={"order_id": order_id}),
"structlog· emitted": lambda: slog.info("order accepted", order_id=order_id),
"loguru · emitted": lambda: loguru_logger.info("order accepted {}", order_id),
}
print(f"{'case':<22}{'ns/call':>10}{'× cheapest':>12}")
results = {name: ns_per_call(fn) for name, fn in CASES.items()}
floor = min(results.values())
for name, value in results.items():
print(f"{name:<22}{value:>10.0f}{value / floor:>11.1f}×")
Expected Output:
case ns/call × cheapest
stdlib · disabled 214 1.0×
structlog· disabled 268 1.3×
loguru · disabled 412 1.9×
stdlib · emitted 10 640 49.7×
structlog· emitted 12 180 56.9×
loguru · emitted 14 920 69.7×
The finding is the gap between the two blocks, not the ordering within them. A filtered call costs roughly a fiftieth of an emitted one in every library, so the decision that matters is how many records you emit — not which library emits them. The measurement technique behind that ratio is the subject of measuring Python logging overhead.
Step 3 — Repeat under concurrency. This is where the libraries genuinely differ, because they lock in different places.
import threading
def concurrent(fn, threads: int, per_thread: int = 20_000) -> float:
barrier = threading.Barrier(threads)
def worker():
barrier.wait()
for _ in range(per_thread):
fn()
ws = [threading.Thread(target=worker) for _ in range(threads)]
start = time.perf_counter_ns()
for w in ws: w.start()
for w in ws: w.join()
return (time.perf_counter_ns() - start) / (threads * per_thread)
for name in ("stdlib · emitted", "structlog· emitted", "loguru · emitted"):
row = [f"{concurrent(CASES[name], t):.0f}" for t in (1, 2, 4, 8)]
print(f"{name:<22}" + "".join(f"{v:>10}" for v in row))
Expected Output:
case 1t 2t 4t 8t
stdlib · emitted 10740 19260 36110 70420
structlog· emitted 12310 21040 38650 74180
loguru · emitted 15020 20880 31940 52260
Step 4 — Add the real sink. Repeat the emitted cases against the production handler and report the difference as the write cost. All three converge there, because they are all calling the same write().
Step 5 — Report ratios. Absolute nanoseconds are a property of the machine. Multiples of the cheapest case on the same run are reproducible and comparable across hardware.
Configuration options
| Variable | Wrong | Right |
|---|---|---|
| Output | text vs JSON | same fields, same JSON |
| Sink | different per library | one shared neutral sink |
| Level | averaged | disabled and enabled, separately |
| Threads | 1 | the service's real worker count |
enqueue / QueueHandler |
on for one library only | reported as its own row |
| structlog config | library default | the production configuration |
| Reporting | absolute ns | multiples of the cheapest case |
Verification
python bench_libs.py
The sanity checks that tell you the harness is sound: the three disabled-path numbers are within a small factor of each other (they are all doing a level check and returning); the emitted numbers are one to two orders of magnitude larger; and re-running produces numbers within a few percent. If any of those fails, fix the harness before reading the ranking.
Then keep the ratio, not the number, as a regression test:
def test_logging_is_not_on_the_critical_path(benchmark):
benchmark(lambda: slog.debug("order accepted", order_id=8812)) # disabled path
assert benchmark.stats["mean"] < 1e-6
Expected Output:
test_bench_libs.py::test_logging_is_not_on_the_critical_path PASSED
Common mistakes
Comparing a console renderer with a JSON one
Error signature: structlog appears several times slower than the alternatives.
Root cause: the default structlog configuration uses ConsoleRenderer with colours and exception pretty-printing, which is a development tool.
Remediation: configure the production chain — JSONRenderer, cache_logger_on_first_use=True, a filtering bound logger.
Measuring the sink instead of the library
Error signature: all three libraries produce nearly identical numbers, dominated by a large constant.
Root cause: the benchmark wrote to a real file, so every case measured the same write() call.
Remediation: run against a neutral in-memory sink first, then repeat with the real one and report the difference explicitly.
Concluding a winner from a single-threaded run
Error signature: a library chosen on benchmark evidence performs worse than expected in production. Root cause: the libraries lock in different places, and the ranking at concurrency one does not hold at four. Remediation: run the thread sweep and read the row that matches your deployment.
What the numbers do not decide
A benchmark answers one question — how much CPU does a log call cost — and the library choice depends on four others, all of which matter more at the volumes most services actually run.
Context handling. Does the library carry request-scoped context across await boundaries and thread hand-offs without the call site passing it explicitly? structlog's contextvars integration does; the standard library needs a filter you write; Loguru's bind() returns a new logger object, which is explicit and does not follow an await on its own. For an async service this is usually the deciding factor, and no throughput number changes it.
Ecosystem fit. Django's LOGGING, Celery's setup_logging, gunicorn's logconfig_dict, pytest's caplog, the OpenTelemetry logs bridge and every third-party library are built around the standard library. A choice that keeps logging as the front end — the standard library directly, or structlog through ProcessorFormatter — inherits all of that. A choice that replaces it needs a bridge for each, and each bridge is a small ongoing cost.
Configurability. dictConfig is declarative, serialisable, and changeable per environment without touching code. Loguru is configured in code by design, which is simpler for a small service and awkward when operations expects to change behaviour without a deploy.
Ergonomics. logger.info("order accepted", order_id=8812) is better than the extra= dance, and a team that writes more log calls because the API is pleasant ends up with better telemetry. This is real and it is the hardest of the four to quantify.
| Criterion | stdlib | structlog | Loguru |
|---|---|---|---|
| Async context | a filter you write | built in via contextvars | explicit binding |
| Ecosystem fit | native | native through ProcessorFormatter |
needs an intercept handler |
| Declarative config | dictConfig |
partly — the chain is code | code only |
| Structured call sites | extra= |
first class | first class |
| Throughput | cheapest per record | within a small factor | comparable, enqueue changes it |
When throughput genuinely decides
There is a threshold above which the CPU numbers stop being a rounding error, and it is worth naming so the benchmark is applied where it matters. As a rough guide: below a few thousand emitted records per second per process, the difference between these libraries is a fraction of a percent of request time and the other four criteria should decide. Above that — a high-throughput ingestion service, a batch job logging per item, a fan-out proxy — the per-record cost becomes a real share of a core, and it is worth measuring on your own workload rather than trusting any of these numbers.
Even there, the first thing to try is not a different library: it is emitting fewer records. The ratio measured on this page says a discarded record costs about a fiftieth of an emitted one, which means halving the record count saves far more than any library switch.
Reproducing this honestly
If you publish numbers of your own, three things make them useful to someone else. State the interpreter version and the machine class, because both move the absolute values by more than the differences being reported. State the configuration for each library in full, including whether enqueue or a queue handler was in play, since that single flag reorders the results. And report ratios against the cheapest case in the same run rather than absolute nanoseconds, so a reader on different hardware can compare their own numbers against yours meaningfully.
Related
- structlog vs Loguru vs stdlib logging — the parent guide: the qualitative comparison these numbers sit beside.
- Measuring Python logging overhead — the harness design this page reuses.
- Python logging performance and overhead — the cost model behind the disabled-versus-emitted ratio.
- Choosing a logging library for FastAPI — the decision these measurements inform.
- Loguru vs structlog for microservices — the context-handling comparison that usually matters more than throughput.
Frequently Asked Questions
Which Python logging library is fastest?
On a like-for-like JSON configuration the standard library with a plain formatter is usually cheapest per emitted record, structlog is within a small multiple of it, and Loguru sits between them depending on whether enqueue is enabled. But that ranking flips as soon as you account for the sink, the concurrency, and whether the comparison forces all three to produce the same output — which is why the harness matters more than any published number.
Is it fair to benchmark Loguru with enqueue enabled?
It is fair as long as you say so and measure both. enqueue moves the write to a background thread, so the caller-side number drops sharply and the work still happens; it is a different trade, not a free win. Report enqueue on and off as two rows, the way you would report the standard library with and without a QueueHandler.
Why does structlog look slow in some benchmarks?
Usually because the benchmark used the default configuration, which includes a console renderer with colours and exception formatting aimed at development. A production configuration with a JSON renderer, cache_logger_on_first_use enabled and a filtering bound logger performs very differently. Benchmark the configuration you would deploy.
Do these numbers matter for my service?
Only above a few thousand records per second per process, or inside a per-row loop. Below that the difference between the libraries is a fraction of a percent of request time, and the decision should be made on ergonomics, context handling and ecosystem fit instead.