Python Logging Performance and Overhead

Logging is never the bottleneck until the day it is — a debug line inside a per-row loop, a JSON formatter running on the request thread, a network handler that blocks when the collector goes slow. This guide gives backend engineers and SREs a cost model for a Python log call, a way to measure each stage, and the specific knobs that remove each cost. It is part of the Python logging fundamentals and structured data section, and the concrete walkthroughs are in measuring Python logging overhead and rate limiting and sampling noisy loggers.

The headline: a log call that is filtered out costs a few hundred nanoseconds, a log call that is emitted costs single-digit microseconds of CPU, and the write costs whatever the sink costs — which is the only one of the three that can be measured in milliseconds.

Where the time in one log call goes Five stages of a single logging call drawn as horizontal bars on a logarithmic time axis running from one hundred nanoseconds to ten milliseconds. The level check is the shortest at roughly two hundred nanoseconds and is paid by every call including the ones that are filtered out. Constructing the LogRecord costs a few microseconds and only happens when the level passes. Caller inspection, which walks the stack to fill in filename, line number and function name, adds several more microseconds and only runs when a formatter asks for those fields. Formatting to JSON costs on the order of ten microseconds. The write dominates everything: a line to a local pipe is tens of microseconds, a synchronous flush to disk is hundreds, and a blocking network sink under stress reaches milliseconds — three to four orders of magnitude above every CPU stage combined. The chart is labelled as indicative shape rather than absolute numbers, measurable with the script on this page. one log call, five stages — note the axis is logarithmic 100 ns 1 µs 10 µs 100 µs 10 ms level check ~0.2 µs · paid even when the record is discarded build the record ~3 µs · only if the level passes caller inspection ~5 µs · only if a formatter asks for filename or lineno format to JSON ~12 µs · movable to a background thread the write pipe ~30 µs · disk flush ~300 µs · blocked network sink: milliseconds indicative shape on a reference machine, not a benchmark result — reproduce it with the script below before tuning anything
Four stages live in the CPU noise. One does not — and it is the only one whose cost depends on something outside your process.

Prerequisites

The measurement work needs nothing but the standard library; the pins below are for the benchmark harness and the async example.

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

Concept and architecture

A call to logger.info(...) runs a fixed sequence, and each step can be removed independently.

The level check comes first. Logger.isEnabledFor() compares the requested level against the cached effective level — a walk up the logger hierarchy that Python memoises in Logger._cache and invalidates whenever any level changes. It is a dictionary lookup and a comparison. The important part is what happens before it: Python evaluates the arguments at the call site, so logger.debug(f"row {row!r}") builds that string whether or not DEBUG is enabled.

Record construction allocates a LogRecord and fills roughly twenty attributes, several of which call into the interpreter: os.getpid(), threading.current_thread(), and time.time(). Three module-level flags — logging.logProcesses, logging.logThreads, logging.logMultiprocessing — switch off the ones you do not print.

Caller inspection is the expensive optional step. Logger.findCaller() walks the stack frame by frame, skipping logging's own frames, to fill filename, lineno, funcName and optionally stack_info. It runs only when the record needs those fields — which, in practice, means whenever your format string mentions them.

Filters and formatting run next, on the calling thread unless a queue intervenes. A JSON formatter serialising a dozen fields is on the order of ten microseconds; a redaction filter running a dozen regexes can be more.

The write is the one that matters. A StreamHandler to a pipe that something is actively draining is fast. The same handler when the reader stalls, a FileHandler doing a rotation check, or an HTTP handler waiting on a collector — those block the calling thread for as long as the sink takes. This is why non-blocking logging with QueueHandler is a latency control, not a micro-optimisation.

Step-by-step implementation

Step 1 — Fix the call sites that pay before the level check. The rule is mechanical: no f-strings, no .format(), no string concatenation, and no repr() of anything large in a logging call. Pass the values; let the formatter interpolate them if it ever needs to.

# pays the cost on every iteration, even with DEBUG off
logger.debug(f"processed row {row!r} in {elapsed:.3f}s")

# pays nothing until a handler actually formats the record
logger.debug("processed row %r in %.3fs", row, elapsed)

# for arguments that are expensive to produce at all
if logger.isEnabledFor(logging.DEBUG):
    logger.debug("plan: %s", explain_query(sql))      # only run the explain if it will be logged
What a disabled DEBUG call costs, three ways Three log call styles measured on the path where DEBUG is switched off. The f-string form is evaluated by the interpreter before the logging call is entered, so the string is built, the repr of the row is produced, and the float is formatted — all of it discarded a microsecond later by the level check. The percent-style form passes the values as arguments, so nothing is interpolated: the call costs only the level check and returns. The isEnabledFor guard goes one step further and skips even producing the argument, which matters when the argument is itself expensive, such as a query plan or a serialised object. A footer notes that the difference is invisible on one call and dominant inside a per-row loop. the same DEBUG line, with DEBUG switched off f-string build the string · repr the row · format the float · then discard all of it full price %-style args level check, return — the values are never touched ~0.2 µs isEnabledFor same, and the expensive argument is never produced either ~0.2 µs when the difference stops being academic one call per request: unmeasurable · one call per row in a 50 000-row import: seconds of CPU per job and the f-string version keeps paying it after someone raises the level to quiet the logs down raising the level is the usual response to log volume — it does nothing at all for the cost of an f-string call site
The percent-style form is not a style preference. It is the difference between a switched-off log line costing nothing and costing its full formatting price forever.

Step 2 — Turn off the record fields nothing reads. These three flags are module-level and take effect for every record created afterwards. Set them at startup, next to dictConfig.

import logging

logging.logProcesses = False        # skips os.getpid() per record
logging.logThreads = False          # skips threading.current_thread() per record
logging.logMultiprocessing = False  # skips the multiprocessing import path per record

Only do this once you have confirmed no format string, no filter and no formatter reads process, thread, threadName or processName. In a containerised service where every process is one replica and thread identity is carried in your own structured fields, all three are dead weight.

Step 3 — Keep caller inspection out of hot paths. %(filename)s:%(lineno)d is genuinely useful in development and genuinely expensive in a loop. Drop it from the production format string; when a wrapper function makes the reported line useless anyway, use stacklevel to point at the real caller instead of paying twice.

def log_with_context(logger, level, msg, *args, **kwargs):
    # stacklevel=2 reports the caller of this helper, not this line
    logger.log(level, msg, *args, stacklevel=2, **kwargs)

Step 4 — Move formatting and I/O off the request path. A QueueHandler in front of the real handlers converts a variable-cost write into a bounded enqueue. The caller builds the record and puts it on a queue; a listener thread runs the filters, formatters and handlers.

import queue
from logging.handlers import QueueHandler, QueueListener

log_queue: queue.Queue = queue.Queue(maxsize=10_000)     # bounded — always
listener = QueueListener(log_queue, *concrete_handlers, respect_handler_level=True)
listener.start()
logging.getLogger().addHandler(QueueHandler(log_queue))

The queue must be bounded and must have an explicit overflow policy, or you have traded a latency problem for a memory problem. The full treatment — drop policies, shutdown draining, what happens to exc_info at the boundary — is in handler architecture.

Step 5 — Shed volume at the source. When one logger produces thousands of near-identical records a second, no amount of queue tuning helps; the records still have to be built, serialised and stored. Rate-limit or sample that logger specifically, keeping a count of what was suppressed. That is the subject of rate limiting and sampling noisy loggers.

Configuration reference

Setting Type Default Production value Effect
logging.logProcesses bool True False Skips os.getpid() per record
logging.logThreads bool True False Skips thread lookup per record
logging.logMultiprocessing bool True False Skips the multiprocessing probe
logging.raiseExceptions bool True False Stops handler errors printing to stderr
%(filename)s / %(lineno)d format absent absent in production Triggers stack inspection
stacklevel int 1 2 in wrappers Correct caller without a second walk
logger.propagate bool True False on leaf loggers with own handlers Avoids duplicate handling
QueueHandler handler absent in front of every sink Bounds caller-side cost
Queue(maxsize=…) int unbounded 10 000 with a drop policy Bounds memory
respect_handler_level bool False True Lets the listener skip work per sink

Async and concurrency considerations

Under asyncio, a synchronous handler is worse than slow: it blocks the event loop, so one stalled write delays every other request the loop is serving. The observable symptom is a P99 that correlates with the log sink's health rather than with your own code, and it does not show up in a single-threaded benchmark at all.

Threads add a second effect. Handler.handle() acquires the handler's lock around emit(), so concurrent loggers serialise on it. With four worker threads writing to one StreamHandler, formatting and writing are effectively single-file, and the queue depth you measure at concurrency four is not four times what you measured at concurrency one.

import asyncio, logging

async def handler(request):
    # a synchronous handler here blocks the loop for the duration of the write
    logging.getLogger("api").info("request %s", request.path)
    return await do_work(request)

The fix is the same in both cases — a queue in front — but the measurement has to be done under the real concurrency, which is the point most benchmarks miss.

What a queue does to the shape of the tail Two latency distributions drawn as histograms for the same request workload. The upper distribution, logging directly to a synchronous handler, has a tight body around the service's own work but a long right tail: every request unlucky enough to log while the sink is slow inherits the sink's latency, so the ninety-ninth percentile sits far to the right of the median and moves whenever the sink does. The lower distribution, logging through a QueueHandler, has the same body and almost no tail, because the caller only pays an enqueue and the variable-cost write happens on a listener thread. A caption notes the trade: the tail returns as record loss or memory growth once the bounded queue saturates, which is why the drop policy is part of the design rather than an afterthought. same workload, same sink, one queue between them direct handler the tail follows the sink — P99 moves when the collector does via QueueHandler the caller pays an enqueue and nothing else faster slower the trade you just made the tail comes back as dropped records or memory growth once the bounded queue saturates — decide which, in advance an unbounded queue does not remove the problem; it converts a latency incident into an out-of-memory one
The body of the distribution barely moves. The queue is not about making logging faster — it is about detaching your tail latency from someone else's sink.

Production code examples

A measurement harness you can run in CI to catch a regression the day it lands, rather than in an incident three months later. It times the four paths that matter and prints a table.

# bench_logging.py — run with: python bench_logging.py
import io
import logging
import time
from statistics import median

def time_call(fn, n: int = 100_000) -> float:
    """Median nanoseconds per call over three batches."""
    batches = []
    for _ in range(3):
        start = time.perf_counter_ns()
        for _ in range(n):
            fn()
        batches.append((time.perf_counter_ns() - start) / n)
    return median(batches)

logging.logProcesses = logging.logThreads = logging.logMultiprocessing = False
sink = logging.StreamHandler(io.StringIO())          # a sink with no I/O cost
sink.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger = logging.getLogger("bench")
logger.addHandler(sink)
logger.setLevel(logging.INFO)

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

cases = {
    "disabled, f-string":   lambda: logger.debug(f"row {row!r}"),
    "disabled, %-args":     lambda: logger.debug("row %r", row),
    "enabled, %-args":      lambda: logger.info("row %r", row),
    "enabled, guarded":     lambda: logger.isEnabledFor(logging.INFO) and logger.info("row %r", row),
}

print(f"{'case':<22}{'ns/call':>10}")
for name, fn in cases.items():
    print(f"{name:<22}{time_call(fn):>10.0f}")

Expected Output:

case                     ns/call
disabled, f-string          4180
disabled, %-args             210
enabled, %-args             9640
enabled, guarded            9720

The absolute numbers depend on the machine; the ratios do not. A disabled f-string call costs roughly twenty times a disabled percent-style call, and an emitted record costs roughly fifty times a discarded one. That second ratio is the argument for sampling rather than for micro-optimising the call site.

For a service, wire the same idea as a pytest-benchmark test with a threshold, so CI fails when someone reintroduces an f-string into a hot loop:

def test_disabled_debug_is_cheap(benchmark):
    logger = logging.getLogger("hot")
    logger.setLevel(logging.INFO)                    # DEBUG is off
    row = {"id": 1}
    result = benchmark(lambda: logger.debug("row %r", row))
    assert benchmark.stats["mean"] < 1e-6            # under a microsecond per call

Expected Output:

test_perf.py::test_disabled_debug_is_cheap PASSED
------------------ benchmark: 1 tests ------------------
Name                        Mean        StdDev
test_disabled_debug     215.3 ns       9.1 ns

Common mistakes

Benchmarking with a null handler and shipping to a network sink. A benchmark whose handler writes to io.StringIO measures the CPU stages and nothing else. It is the right tool for comparing call styles and the wrong one for capacity planning — the real cost is the write, and the write is exactly what the harness removed.

Raising the log level to fix a performance problem. It reduces the number of records emitted, which helps, but every f-string call site keeps paying full price, and the loop that produced the volume still runs. Fix the call sites and sample the noisy logger; the level is a blunt instrument that also removes the records you wanted.

Adding a queue and leaving it unbounded. queue.Queue() with no maxsize never blocks and never drops — it grows. Under a sink outage that is an out-of-memory kill instead of a latency spike, usually at the worst possible moment. Bound it and decide explicitly what gets shed first.

Measuring single-threaded. Handler locks serialise emit() across threads, so the interesting contention only appears at your real worker count. A benchmark at concurrency one systematically under-reports the cost of a synchronous handler in a threaded WSGI server.

Leaving caller information in the production format string. %(filename)s:%(lineno)d forces a stack walk on every emitted record. It is worth it in development; in a service that emits thousands of records a second it is several percent of a core, spent re-deriving something the logger name already tells you.

Sampling errors. Every sampling scheme should exempt WARNING and above. Errors are rare by definition, and the one you dropped is the one the incident was about.

Budgeting the pipeline end to end

Per-call cost is only half the picture. The other half is what a log record costs after it leaves the process, and that number decides how much of the first half is worth optimising.

A rough model, for a service emitting 2 000 records per second per replica across twenty replicas: 40 000 records per second, at roughly 600 bytes each once serialised, is about 24 MB/s or two terabytes a day before compression. Ingestion is usually billed on that volume, retention on the compressed result, and query cost on how much has to be scanned. Against those numbers, ten microseconds of CPU per record is 0.4 of a core across the fleet — real, but an order of magnitude less significant than the storage.

That ratio has a practical consequence: reduce record count before reducing per-record cost. Every technique on this page divides into those two categories, and they are not equally valuable.

Technique Reduces Typical effect Effort
Percent-style call sites per-call CPU on the disabled path large ratio, small absolute one lint rule
Switching off logProcesses/logThreads per-record CPU ~10% of an emitted record one line
Removing caller info from the format per-record CPU ~30–60% of an emitted record one line
A queue in front of the sinks caller-side latency tail latency, not volume a few lines
Rate limiting a noisy logger record count often 50–90% on the affected logger one filter
Raising a logger's level record count large, and indiscriminate one line
Removing a log call record count and cost total, for that call a code review

The last row is the one worth revisiting periodically. Services accumulate log calls that made sense during a migration three years ago and have not been read since; a query for the record shapes nobody has ever filtered on usually finds several of them.

Where the budget actually goes

Two shapes account for most surprises. The first is a per-item log call inside a loop that processes a batch: one record per row in a 50 000-row import is 50 000 records for a job that produced one business outcome, and the fix is to log the outcome plus a count rather than each item. The second is a debug call left at INFO after an investigation, which is invisible in code review because the line looks identical to a legitimate one.

# 50 000 records for one job
for row in rows:
    logger.info("imported row %s", row.id)

# one record, and the same information
logger.info("import finished", extra={"rows": len(rows), "failed": failures, "duration_s": elapsed})

The second form is also better telemetry: rows and failed are fields you can chart, whereas fifty thousand individual records are something you can only count.

Deciding what to keep

A record earns its place if someone would query it. In practice that reduces to three categories: records that describe a state change worth auditing, records that carry a failure with enough context to act on, and records that mark a boundary — a request starting, a job finishing — that other records hang off. Anything that does not fall into one of those is a candidate for deletion or for a metric, and a metric is almost always cheaper: a counter increment is nanoseconds and one series, against microseconds and several hundred bytes of storage for the equivalent record.

The test for the third category is worth applying literally: open the log search, filter for that record shape over the last thirty days, and look at whether anyone ever narrowed it further. A record shape that only ever appears in an unfiltered tail view is a record shape nobody reads, and deleting the call is both the cheapest optimisation available and an improvement to the signal-to-noise ratio of everything around it. Records that fail the test rarely fail it narrowly.

That substitution is the single largest saving available in most services, and it is a design decision rather than a tuning one. The instrument choice behind it is covered in choosing between Counter, Gauge, Histogram and Summary.

Frequently Asked Questions

How expensive is a Python log call that is filtered out by level?

A few hundred nanoseconds — Logger.isEnabledFor consults a cached effective level and returns. That is cheap enough to ignore, with one exception: the arguments were evaluated before the call, so an f-string or a repr of a large object costs its full price whether or not the record is ever emitted.

Is an f-string in a log call really a problem?

It is when the level is disabled or the call is hot. Percent-style arguments are stored on the record and interpolated only if something formats it, so a disabled DEBUG call costs a level check. An f-string is evaluated by the interpreter before logging is entered, so you pay the formatting cost every time, forever, including on the path where the record is thrown away.

Does a QueueHandler make logging free?

No, it moves the cost. The caller still builds the record and enqueues it; serialisation and I/O move to the listener thread. That converts a variable, sometimes multi-millisecond write into a bounded enqueue, which is what protects tail latency — but under sustained overload the queue fills and you are back to choosing between blocking and dropping.

Which record fields can I switch off?

logging.logProcesses, logging.logThreads and logging.logMultiprocessing each populate fields most services never print. Turning them off removes per-record work. Caller information — filename, lineno, funcName — is the expensive one, and it is computed only when a handler's format string or your formatter asks for it.

Should I sample logs the way I sample traces?

For repetitive, high-volume records, yes — but sample by logger and event rather than uniformly, and never sample errors. The goal is to keep one representative record per burst plus an accurate count, not to reduce every stream by the same factor.

Where does logging overhead actually show up in production?

Rarely as average latency; almost always as tail latency and as an event loop stall. A synchronous handler writing to a slow disk or a network sink blocks whatever thread called it, so the symptom is a P99 that tracks the sink's health rather than your own code.