Measuring Python Logging Overhead

Before you optimise a logging pipeline, you need to know which of its four costs you are actually paying. This page builds a benchmark that attributes time to argument evaluation, the level check, record construction, and the write — and then re-runs it under concurrency, where the answer changes. It is for engineers who suspect logging is on the critical path and want a number rather than an opinion. It belongs to the Python logging performance and overhead guide, part of the Python logging fundamentals and structured data section.

The single most common measurement error is benchmarking against a sink that does no work, then concluding logging is cheap. It is cheap — right up to the handler.

Four cells, four different questions A two-by-two benchmark design. The columns are the sink: a null stream that discards output, and the real production handler. The rows are the level: switched off, and switched on. The disabled-level cell against a null sink measures argument evaluation plus the level check, and is the cost paid by every hot-path call in the service. The disabled-level cell against the real sink measures the same thing, because a filtered call never reaches the handler, so the two should be identical and a difference means something is wrong with the level configuration. The enabled cell against a null sink measures record construction plus filters plus formatting. The enabled cell against the real sink measures all of that plus the write, so subtracting the null-sink number gives the write cost on its own — the number that decides whether a queue is needed. run all four — each cell isolates a different cost null sink the real handler level off the hot path argument evaluation + level check f-string vs %-args shows up here every request pays this, forever target: under 1 µs identical to the cell on its left a filtered call never reaches a handler if these two differ, a handler level or a filter is doing work it should not level on what you emit record + filters + formatting the CPU cost of an emitted record JSON, redaction, caller lookup typically 5–20 µs all of that, plus the write subtract the cell on its left = write cost the only stage measured in milliseconds this is the one that decides the queue most published Python logging benchmarks report the left column only, which is why they conclude logging is free
The right-hand column is the one nobody publishes, and the only one that predicts what happens when the collector goes slow.

Prerequisites

pip install "pytest-benchmark>=4.0.0,<6.0.0" \
            "python-json-logger>=2.0.7,<4.0.0"
export PYTHONHASHSEED=0     # stable dict ordering between runs

Pin the interpreter to whatever production runs. Logging's hot path has changed between minor versions, so a number from 3.11 does not transfer to 3.13.

Implementation

Step 1 — Build a harness that reports medians, not means. A single timing run on a shared CI machine is noise. Take three batches and report the median; a mean is dragged around by whichever batch collided with another process.

# bench.py
import io
import logging
import time
from statistics import median

def ns_per_call(fn, n: int = 100_000, batches: int = 3) -> float:
    fn()                                          # warm caches and imports
    times = []
    for _ in range(batches):
        start = time.perf_counter_ns()
        for _ in range(n):
            fn()
        times.append((time.perf_counter_ns() - start) / n)
    return median(times)

Step 2 — Measure the disabled path, both call styles. This is the number that applies to every hot-path call site in the codebase, including the ones nobody looks at because "DEBUG is off in production anyway".

logger = logging.getLogger("bench")
logger.setLevel(logging.INFO)                     # DEBUG is disabled
logger.addHandler(logging.StreamHandler(io.StringIO()))

row = {"id": 42, "email": "a@example.com", "items": list(range(20))}

print("disabled f-string ", ns_per_call(lambda: logger.debug(f"row {row!r}")))
print("disabled %-args   ", ns_per_call(lambda: logger.debug("row %r", row)))

Step 3 — Attribute the record-construction cost field by field. Toggle one flag at a time and re-measure. Bundling them tells you the total; separating them tells you which one to switch off.

def emitted() -> None:
    logger.info("row %r", row)

logging.logProcesses = logging.logThreads = logging.logMultiprocessing = True
baseline = ns_per_call(emitted, n=20_000)

logging.logProcesses = False
no_pid = ns_per_call(emitted, n=20_000)

logging.logThreads = logging.logMultiprocessing = False
no_thread = ns_per_call(emitted, n=20_000)

print(f"all fields {baseline:.0f} ns · no pid {no_pid:.0f} ns · minimal {no_thread:.0f} ns")

Then do the same for caller information, which is controlled by the format string rather than by a flag: measure once with %(message)s and once with %(filename)s:%(lineno)d %(message)s, and the difference is what the stack walk costs you.

An emitted record, attributed field by field A single stacked bar breaking down the cost of one emitted log record measured against a null sink. The base cost of constructing the record and running the level check accounts for the largest single share. The process id field, populated when logProcesses is left on, adds a small slice. The thread and multiprocessing fields add another. Caller inspection — the stack walk that fills filename, line number and function name, triggered by a format string that mentions them — adds the second largest slice, comparable to everything before it combined. JSON formatting adds the remainder. Each slice is switchable independently, which is the point of measuring them separately rather than reporting one total. one emitted record against a null sink, by stage record construction JSON pid thread + mp caller inspection what each slice costs you, and how to remove it record construction — irreducible once the level check passes; the lever is emitting fewer records, not cheaper ones pid, thread, multiprocessing — three module flags, off in a container where nothing reads those fields caller inspection — a stack walk; remove %(filename)s and %(lineno)d from the production format string JSON formatting — movable to the listener thread with a QueueHandler, not removable measure each by toggling one thing and re-running — a single total tells you nothing about which lever to pull
Two of these five slices come off with three module-level flags and one format-string edit. Knowing which two is the entire reason to attribute rather than total.

Step 4 — Swap in the real sink. Repeat the enabled-level measurement against the actual production handler — the file, the socket, the OTLP exporter — and subtract the null-sink number. What remains is the write.

real = logging.getLogger("bench.real")
real.setLevel(logging.INFO)
real.addHandler(production_handler())             # the one from your dictConfig
with_write = ns_per_call(lambda: real.info("row %r", row), n=5_000)
print(f"write cost ≈ {with_write - no_thread:.0f} ns")

Use fewer iterations here: five thousand writes to a real file is already a meaningful amount of I/O, and a hundred thousand will tell you about your disk's write cache rather than about logging.

Step 5 — Re-run under concurrency. Handler.handle() takes the handler's lock around emit(), so N threads logging to one handler do not get N times the throughput. This is the step that separates a benchmark from a prediction.

import threading

def concurrent(threads: int, per_thread: int = 20_000) -> float:
    barrier = threading.Barrier(threads)
    def worker():
        barrier.wait()                            # start together
        for _ in range(per_thread):
            real.info("row %r", row)
    workers = [threading.Thread(target=worker) for _ in range(threads)]
    start = time.perf_counter_ns()
    for w in workers: w.start()
    for w in workers: w.join()
    return (time.perf_counter_ns() - start) / (threads * per_thread)

for n in (1, 2, 4, 8):
    print(f"{n} threads: {concurrent(n):.0f} ns/call")
What handler lock contention looks like Per-call cost in nanoseconds plotted against thread count at one, two, four and eight threads. With a direct handler the cost climbs steeply, because every thread must acquire the same handler lock before emit runs, so formatting and writing are effectively serialised and each additional thread mostly adds waiting. With a QueueHandler in front, the cost stays close to flat: the lock is held only for the enqueue, which is short and uncontended, and the actual formatting and writing happen on a single listener thread that never competes with the workers. A footer notes that the single-threaded measurement, the one most benchmarks report, is the leftmost point where the two designs look identical. the same handler, measured at four thread counts ns/call 1 thread 2 threads 4 threads 8 threads direct handler via QueueHandler the single-threaded number — where both designs look the same, and where most benchmarks stop shape from a reference run; reproduce it with the concurrent() helper above before drawing conclusions about your own sink
One thread is the only measurement where a direct handler and a queued one agree. Every number that matters is to the right of it.

Configuration options

Knob Where Effect on the measurement
n iterations harness Below ~10 000, timer resolution dominates
batches + median harness Removes single-run interference on shared CI
Null vs real sink handler Separates CPU cost from write cost
logProcesses / logThreads module flags Each removes per-record work
%(filename)s in format formatter Triggers the stack walk; measure with and without
Thread count harness Exposes handler lock contention
PYTHONHASHSEED env Keeps dict iteration order stable

Verification

Run the whole matrix and check the shape of the result before trusting any single number.

python bench.py

Expected Output:

disabled f-string   4180 ns
disabled %-args      210 ns
all fields          9640 ns
no pid              9120 ns
minimal             8460 ns
with caller info   13980 ns
write cost ≈       21400 ns
1 threads: 31200 ns/call
2 threads: 48700 ns/call
4 threads: 79300 ns/call
8 threads: 142500 ns/call

Three things in that output are the actual findings: the disabled f-string costs twenty times the disabled percent-style call, caller information adds roughly 60 percent to an emitted record, and per-call cost more than quadruples from one thread to eight. The first is a lint rule, the second is a format-string decision, and the third is the argument for non-blocking logging with QueueHandler.

Then freeze the two numbers that matter as a CI assertion:

def test_hot_path_logging_stays_cheap(benchmark):
    logger = logging.getLogger("hot")
    logger.setLevel(logging.INFO)
    payload = {"id": 1}
    benchmark(lambda: logger.debug("row %r", payload))
    assert benchmark.stats["mean"] < 1e-6           # a disabled call must stay sub-microsecond

Expected Output:

test_bench.py::test_hot_path_logging_stays_cheap PASSED

Common mistakes

The benchmark says logging is free

Error signature: measured cost of a few microseconds per call, while production P99 moves with the log sink's health. Root cause: the handler wrote to io.StringIO, so the write — the only stage that can take milliseconds — was excluded. Remediation: run the enabled-level case against the real handler as well and report the difference explicitly as the write cost.

Results move 30 percent between runs

Error signature: consecutive runs disagree well beyond any change you made. Root cause: too few iterations, a single batch, or CPU frequency scaling on a laptop. Remediation: raise iterations until a batch takes tens of milliseconds, take the median of three, and pin the benchmark to a machine class in CI rather than comparing across runners.

The concurrency case is never run

Error signature: a queue is judged unnecessary from a single-threaded benchmark, then added six months later during an incident. Root cause: handler lock contention does not exist at concurrency one, which is where the benchmark stopped. Remediation: run the thread sweep at your real worker count. If the per-call cost climbs with threads, the lock is the finding, not the sink.

Turning the numbers into decisions

A benchmark that produces numbers nobody acts on is a slow way of confirming an intuition. Three thresholds are worth agreeing in advance, so the result of a run leads directly to a decision rather than to a discussion.

Is the disabled path free enough? Below roughly one microsecond per call, a disabled log call is invisible even in a tight loop, and no further work is justified. Above five microseconds, something is evaluating arguments at the call site — an f-string, a repr of a large object, a function call that produces the value — and the fix is at the call site rather than in the configuration. That is a lint-level rule and can be enforced mechanically.

Is the emitted path worth optimising? Compare the CPU cost per emitted record against the request's own budget. At 10 microseconds per record and 10 records per request, logging is 100 microseconds of a request that takes 40 milliseconds: a quarter of a percent, and not where the time is. At 10 records per row in a batch job processing 50 000 rows, the same number is five seconds per job, and it is worth attention. The absolute cost does not change; its significance does entirely.

Does the write need a queue? This is the only one of the three that is not really about CPU. If the measured write cost has a heavy upper tail — most writes fast, occasional writes in the milliseconds — the service's tail latency is coupled to the sink's health, and a queue decouples them regardless of the average. A flat, fast write distribution to a local pipe genuinely does not need one.

Measurement Threshold Decision
Disabled call > 1 µs fix the call sites: percent-style args, isEnabledFor guards
Emitted record, CPU > 1% of request budget remove fields, drop caller info, reduce record count
Write cost, p99 > 10× the p50 put a queue in front and bound it
Concurrency slope rising with threads the handler lock is the constraint, not the sink

Making the benchmark part of the build

The value of these numbers decays quickly, because the thing they measure changes with every dependency upgrade and every new field in the formatter. Running the harness once during an investigation answers today's question; running it in CI answers the question you have not asked yet.

# tests/test_logging_perf.py
import logging
import pytest

@pytest.mark.benchmark(group="logging")
def test_disabled_call_stays_sub_microsecond(benchmark):
    logger = logging.getLogger("hot")
    logger.setLevel(logging.INFO)
    payload = {"id": 1, "items": list(range(20))}
    benchmark(lambda: logger.debug("row %r", payload))
    assert benchmark.stats["mean"] < 1e-6

@pytest.mark.benchmark(group="logging")
def test_emitted_record_stays_under_budget(benchmark):
    logger = logging.getLogger("emit")
    logger.setLevel(logging.INFO)
    benchmark(lambda: logger.info("row %r", {"id": 1}))
    assert benchmark.stats["mean"] < 30e-6

Expected Output:

tests/test_logging_perf.py::test_disabled_call_stays_sub_microsecond PASSED
tests/test_logging_perf.py::test_emitted_record_stays_under_budget PASSED

Two assertions, both loose enough that ordinary machine variation does not trip them, and both tight enough to catch the specific regressions that matter: an f-string reintroduced into a hot path, and a formatter that started doing something expensive. Pin them to a machine class in CI rather than comparing across runner types, because a shared runner's variance is larger than most of the effects being measured.

What not to measure

Two figures look informative and are not. Records per second from a loop with no other work tells you the maximum rate of a benchmark, not of a service, because a real service spends most of its time on something else and the logging path is never warm in the same way. And the memory cost of a log record is dominated by whatever the record references rather than by the record itself, so measuring sys.getsizeof on a LogRecord returns a number that is both accurate and useless.

Frequently Asked Questions

Why does my benchmark say logging is free when production says otherwise?

Because the benchmark's handler wrote to a StringIO or a null stream. That measures the CPU stages and deliberately removes the write, which is the only stage that can cost milliseconds. Run the benchmark twice — once against a null sink and once against the real one — and treat the difference as the write cost.

Should I use timeit or perf_counter?

Either, as long as you take a median of several batches rather than a single run and disable the garbage collector's interference by running enough iterations to amortise it. timeit already disables the cycle collector during a run; a hand-rolled perf_counter_ns loop does not, which is one reason results differ between them.

How many iterations do I need?

Enough that one batch takes tens of milliseconds — typically 100 000 for a disabled call and 10 000 for an emitted one. Below that, timer resolution and CPU frequency scaling dominate and the numbers move by 30 percent between runs for no reason.

Does the benchmark need to run under the same Python version as production?

Yes, and ideally the same build. The logging module's hot path has changed measurably between versions — level-check caching, the record factory, and the lazy import in the multiprocessing probe all differ — so a number measured on one minor version is not transferable to another.