Lazy Formatting and Expensive Log Arguments

A debug statement that is never emitted can still cost milliseconds on every call, because Python evaluates a function's arguments before the function runs — and the logger's level check happens inside the function. This page covers exactly what is deferred and what is not, why f-strings defeat the level check, and the patterns that make disabled logging genuinely free on a hot path. It is a task article under logging performance and overhead, part of the Python logging fundamentals and structured data section.

What runs before the level check Three versions of a debug statement are traced through a call where the logger's level is INFO, so the record will be discarded. In the first, an f-string containing a serialised order object is evaluated before the logger is called: the serialisation and the string formatting both run, costing several hundred microseconds, and then the logger discards the result. In the second, the logging module's argument substitution is used with the serialisation call as the argument: the string formatting is deferred and skipped, but the serialisation still runs because arguments are evaluated before any function call. In the third, the call is guarded by an isEnabledFor check: neither the serialisation nor the formatting runs, and the cost is a single cached comparison. The note records that the second version is the common default and is sufficient for cheap arguments, but only the third makes an expensive argument free. logger level is INFO · the debug record will be discarded log.debug(f"order {serialize(order)}") serialize() runs f-string builds then discarded · ~400 µs wasted log.debug("order %s", serialize(order)) serialize() still runs formatting skipped · ~250 µs wasted if log.isEnabledFor(DEBUG): log.debug("order %s", serialize(order)) one cached comparison · effectively free argument substitution defers formatting — it cannot defer evaluating the arguments Python evaluates every argument before any function is called, including the logger
The middle row is the common default and is fine for cheap arguments. Only the guard stops an expensive argument from being computed and thrown away.

Prerequisites

The standard library is all that is needed. A benchmark harness makes the measurements in the verification section reproducible.

pip install "pyperf>=2.7.0,<3.0.0"

Implementation

Step 1 — Pass arguments to the logger instead of pre-formatting. The logging module substitutes arguments into the message only when a handler formats the record, and a record discarded by the level check is never formatted. Passing arguments separately therefore defers all string building until it is certain to be needed. It also keeps the message template constant, which means records group correctly in a log store — a second benefit that is often worth more than the first.

import logging
log = logging.getLogger("orders")

# formatting deferred; template constant and groupable
log.debug("order %s total %d items %d", order.id, order.total, len(order.items))

# formatting happens now, even if DEBUG is disabled
log.debug(f"order {order.id} total {order.total} items {len(order.items)}")

Step 2 — Guard genuinely expensive arguments. Deferral covers formatting, not evaluation: Python evaluates every argument expression before calling any function. An argument that serialises a large object, walks a data structure or queries something is computed in full and then thrown away. An explicit level check around the call skips the evaluation entirely, at the cost of one comparison that the logging module caches.

if log.isEnabledFor(logging.DEBUG):
    log.debug("order snapshot %s", json.dumps(order.to_dict(), default=str))

Step 3 — Use a lazy wrapper where a guard is awkward. Guards are clear but noisy when repeated. A small wrapper whose string conversion performs the work gives the same effect inside an ordinary log call: the wrapper object is created cheaply, and the expensive computation runs only if a formatter converts it to a string.

class Lazy:
    """Defer an expensive computation until the record is actually formatted."""
    __slots__ = ("_fn",)

    def __init__(self, fn):
        self._fn = fn

    def __str__(self) -> str:
        return str(self._fn())

log.debug("order snapshot %s", Lazy(lambda: json.dumps(order.to_dict(), default=str)))

Step 4 — Apply the same thinking to structured fields. Fields passed through extra are evaluated at the call too. A structured log call building a large dictionary of context pays for it whether or not the record is emitted. The guard applies unchanged; with structlog, a processor that computes expensive fields — placed after the level filter in the processor chain — achieves the same deferral declaratively, as described in writing custom structlog processors.

Step 5 — Keep side effects out of log arguments. An argument whose evaluation changes state — incrementing a counter, consuming an iterator, advancing a cursor — runs or does not run depending on the log level if it is guarded, and always runs if it is not. Either way the program's behaviour now depends on logging configuration, which is the kind of bug that appears only when somebody lowers a level to investigate a different problem.

# a consumed iterator changes behaviour depending on whether this line runs
log.debug("first item %s", next(items))          # don't

first = next(items)                              # do: side effect independent of logging
log.debug("first item %s", first)

Step 6 — Measure the disabled path. On a genuinely hot path, confirm the statement is free when its level is disabled. The comparison of the three forms in a tight loop makes the cost visible and settles arguments about whether a guard is worth its noise.

The cost of a record nobody sees A disabled debug statement is timed in four forms over a million calls, with the logger set to INFO. A plain call with cheap arguments passed separately costs about a quarter of a microsecond, the price of a function call and a cached level comparison. The same statement as an f-string with cheap values costs about one microsecond, because the string is built every time. A call passing an expensive serialisation as an argument costs about two hundred and fifty microseconds, because the serialisation runs even though formatting is skipped. The same call guarded by isEnabledFor costs about a tenth of a microsecond. The note records that on a path handling ten thousand requests per second, the difference between the third and fourth forms is two and a half seconds of CPU per second, which is to say an entire core spent building strings nobody reads. per call, logger at INFO, statement at DEBUG guarded 0.1 µs args passed separately 0.25 µs f-string, cheap values 1 µs expensive argument 250 µs — the serialisation runs and is thrown away at 10 000 calls a second, that last row is 2.5 CPU-seconds per second an entire core building strings that the level check discards
The ordinary cases are cheap and need no special treatment. The expensive argument is the one that matters, and it is invisible in every profile that does not look for it.

Where expensive arguments hide

The obvious case — json.dumps of a large object in a debug statement — is easy to spot in review. Several less obvious forms are worth knowing, because they look cheap at the call site.

Implicit __repr__ and __str__. Passing an ORM object or a large dataclass as an argument looks free, and with deferred formatting it is — until the record is emitted, at which point the object's representation is computed. Some representations are expensive: an ORM object whose __repr__ loads related objects triggers queries during formatting. Under a QueueHandler, that formatting happens on the listener thread, possibly after the request's database session has closed, which produces errors that appear to come from nowhere.

Properties and descriptors. order.total looks like an attribute read and may be a property that computes a sum over line items, loading them from the database if they are not already loaded. Used as a log argument it runs at the call, regardless of level.

Comprehensions building context. extra={"ids": [i.id for i in items]} builds the list whether or not the record is emitted. For a handful of items this is nothing; for ten thousand it is a measurable allocation on every call.

Calls into other systems. A log argument that calls a cache, a feature flag service or a configuration lookup to include its current value in the record performs a network round trip on every call. This is rare and devastating when it happens on a hot path.

The pattern common to all four is that the cost is not visible at the call site and appears in a profile as time spent in the called function rather than in logging. Profiles that show unexpected time in serialisation or property access from logging call sites — see measuring Python logging overhead — point straight at them.

The level check itself

It is worth understanding why the disabled path is cheap when the arguments are, because it explains the numbers above and a subtle way to make them worse.

Each logger caches the result of its effective level computation. The first call at a given level walks up the logger hierarchy to find the effective level and stores the answer; subsequent calls read the cached value. That cache is invalidated whenever any logger's level changes, anywhere in the process. In normal operation that never happens after startup, and the check costs a dictionary lookup.

Two patterns defeat the cache. The first is changing log levels frequently at runtime — for example, from a request handler that raises a logger's level for one request and lowers it again. Each change clears every logger's cache, and the next call on every logger pays for the full hierarchy walk. The runtime level changes described in changing log levels at runtime are fine because they happen rarely; per-request toggling is not.

The second is creating loggers dynamically — a logger per request, per tenant, or per object, named with a unique identifier. Each new logger is added to the manager's registry permanently, the registry grows without bound, and the first check on each new logger pays for a walk. A fixed set of module-level loggers, with variable context carried as fields rather than in logger names, keeps the check cheap and the registry small.

With both avoided, the disabled path really is a function call and a lookup, and the only thing that can make it expensive is what the caller computes before making the call — which is what the rest of this page is about.

Cost of one disabled DEBUG call A bar chart of the time one DEBUG call takes when DEBUG is disabled, for four ways of writing it. Percent-style arguments with a cheap value: about 0.1 microseconds, just the level check. An f-string with a cheap value: about 0.4 microseconds, because the string is built before the check. An f-string that calls json.dumps on a large object: about 180 microseconds, all wasted. A percent-style call guarded by isEnabledFor around the expensive work: about 0.1 microseconds. The note says the argument expressions run before logging decides to drop the record, so expensive work needs a guard. one DEBUG call with DEBUG disabled %-style, cheap value ~0.1 µs f-string, cheap value ~0.4 µs f-string with json.dumps(big) ~180 µs · all wasted isEnabledFor guard ~0.1 µs arguments are evaluated before logging decides to drop the record guard expensive work with isEnabledFor, or pass a lazily formatted object
The level check is cheap; the argument expressions are not skipped by it. Expensive arguments need an explicit guard.

Configuration options

Pattern Defers formatting Defers argument evaluation Use for
log.debug("… %s", x) yes no the default, cheap arguments
log.debug(f"… {x}") no no avoid in logging calls
isEnabledFor guard yes yes expensive arguments
Lazy(lambda: …) wrapper yes yes, until formatting expensive arguments inline
structlog processor after level filter yes yes computed context fields
Argument with side effects — — never

Verification

Measure the forms in a tight loop with the statement's level disabled.

import json, logging, timeit

log = logging.getLogger("bench"); log.setLevel(logging.INFO)
payload = {"items": list(range(500)), "meta": {"k": "v" * 200}}

cases = {
    "guarded":  lambda: log.isEnabledFor(logging.DEBUG) and log.debug("x %s", json.dumps(payload)),
    "args":     lambda: log.debug("x %s %s", 1, "a"),
    "f-string": lambda: log.debug(f"x {1} {'a'}"),
    "expensive":lambda: log.debug("x %s", json.dumps(payload)),
}
for name, fn in cases.items():
    per_call = timeit.timeit(fn, number=100_000) / 100_000 * 1e6
    print(f"{name:10s} {per_call:8.2f} µs")

Expected Output: the expensive argument dominating by two to three orders of magnitude.

guarded        0.09 µs
args           0.24 µs
f-string       0.97 µs
expensive    246.10 µs

Common mistakes

f-strings in log calls. Error signature: formatting cost on every call and messages that cannot be grouped. Root cause: the string built before the logger is called. Remediation: pass arguments separately.

Assuming deferral covers arguments. Error signature: a disabled debug statement showing up in a CPU profile. Root cause: arguments evaluated before the call. Remediation: guard expensive arguments.

Expensive representations formatted off-thread. Error signature: database errors raised from the log listener thread. Root cause: an ORM object's __repr__ running during deferred formatting after its session closed. Remediation: log identifiers, not objects.

Side effects in arguments. Error signature: behaviour that changes when a log level changes. Root cause: a consuming or mutating call inside a log argument. Remediation: compute first, then log the result.

Guarding everything. Error signature: code dense with level checks around trivial statements. Root cause: applying the expensive-argument pattern universally. Remediation: guard only where measurement shows cost.

Frequently Asked Questions

Why is an f-string in a log call a problem?

Python evaluates the f-string before calling the logger, so the full message is built even when the logger discards the record because of its level. With the logging module's own argument substitution, the string is only built during formatting, which never happens for a discarded record.

Does lazy formatting make expensive arguments free?

No. The arguments themselves are still evaluated when the call is made; only the final string interpolation is deferred. log.debug('%s', expensive()) still calls expensive(). Guarding with isEnabledFor, or wrapping the value lazily, is what avoids that.

How expensive is a disabled log call?

A call to a disabled level with plain arguments costs roughly a function call and a cached level comparison — well under a microsecond. It becomes expensive only through what the arguments do before the call: formatting, serialisation, database queries, string concatenation of large objects.

Should every debug statement be guarded?

No. Guards add noise and are only worth it when the arguments are genuinely expensive. The default of passing arguments to the logger handles the common case; guards are for serialising large structures or computing diagnostics.