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.

Four variables to fix before any number means anything Four experimental variables that invalidate a logging benchmark when left uncontrolled. Output shape: comparing a plain text formatter against a JSON renderer measures serialisation rather than the library, so all three must emit the same fields. Sink: comparing one library writing to a null stream against another writing to a file measures the file system, so the sink must be identical and neutral. Level state: an emitted record and a filtered one differ by roughly fifty times, so the disabled path and the enabled path are separate measurements rather than one average. Concurrency: the libraries lock in different places, so a single-threaded ranking does not predict behaviour at the service's real worker count. Each row shows the uncontrolled version on the left and the controlled version on the right. before comparing anything, equalise these four output text formatter vs JSON renderer the same fields, the same JSON, from all three sink null stream vs a real file one neutral sink, then repeat with the real one level one average across both paths disabled and enabled measured separately threads a single-threaded loop the worker count the service actually runs the ratio between a filtered call and an emitted one is roughly fifty to one — averaging them produces a number that describes your call mix rather than the library, and it changes when someone adds a debug line
Most published comparisons control one or two of these. The level row alone is enough to reverse a ranking, because the two paths differ by a factor of fifty.

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
The ranking at one thread does not survive to eight Per-call cost in nanoseconds plotted against thread count at one, two, four and eight threads for three libraries. At a single thread the standard library is cheapest, structlog is slightly behind it, and Loguru is the most expensive of the three. As thread count rises all three get more expensive per call, because each serialises writes somewhere, but they diverge: the standard library and structlog, which both go through a handler lock held across formatting and writing, climb steeply and stay close together, while Loguru climbs more slowly and crosses below both by four threads. The practical reading is that a benchmark stopping at one thread would recommend the opposite library to a benchmark run at the service's real worker count, and neither number is wrong — they answer different questions. per-call cost as threads are added ns/call 1 thread 2 threads 4 threads 8 threads stdlib structlog loguru the crossing point — a benchmark that stops at one thread recommends a different library than one run at four neither is wrong; they answer different questions, and only one of them is the question your service asks
The lines cross. Which library "wins" is a function of the thread count you measured at, which is why the harness has to run at yours.

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.

The gap that matters is between the paths, not between the libraries All six measured cases drawn as bars on one shared scale. The three disabled-path cases — standard library, structlog and Loguru — sit clustered at the far left, differing from each other by less than a factor of two and all under half a microsecond. The three emitted-path cases sit far to the right, an order of magnitude and a half away, differing from each other by about forty percent. The visual point is that the horizontal distance between any two libraries on the same path is small compared with the distance between the two paths, so a decision about how many records to emit dominates a decision about which library emits them. A footer adds the corollary: the only change that moves a service between the two clusters is emitting fewer records, which is a filtering and sampling decision rather than a library choice. all six cases on one scale stdlib · disabled 214 ns structlog · disabled 268 ns loguru · disabled 412 ns stdlib · emitted 10 640 ns structlog · emitted 12 180 ns loguru · emitted 14 920 ns the only change that moves a service between the two clusters is emitting fewer records — a filtering decision, not a library one
Everything in the top cluster is a rounding error next to the bottom one. The library you pick moves you a few percent; the records you choose not to emit move you fifty-fold.

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.

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.