Why pass logger.info('row %s', key) instead of an f-string message?
answer
- Formatting can wait
- The record keeps template and values apart
- Interpolation only if something emits
- An f-string is built at the call site
- getMessage() does msg % args
basics
~20 sThe template and its arguments are stored separately on the record and interpolated only if a handler actually emits it, so a filtered-out call costs nothing. An f-string is built at the call site every time, whatever the level.
solid answer
~40 s`logger.info("row %s", key)` stores `"row %s"` as `record.msg` and `(key,)` as `record.args`; the `%` interpolation happens inside `record.getMessage()` only when a handler formats the record. If the level filters the call, no record is even created and no string is built. An f-string, by contrast, is evaluated before `info()` is called - every time, at every level. The second benefit is the stable template: because `record.msg` stays `"row %s"`, downstream formatters and analysis can group thousands of lines by one message shape instead of thousands of unique strings. The caveat is that the *arguments* are still evaluated eagerly, so an expensive expression needs guarding with `logger.isEnabledFor(...)` regardless of style.
code
python · 13 linesimport logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ingest")
def parse_cost():
print("evaluated")
return 42
logger.debug("cost %s", parse_cost())
logger.debug("cost %s", 42)
record = logging.LogRecord("ingest", logging.DEBUG, __file__, 1, "cost %s", (42,), None)
print(record.msg, record.args, record.getMessage())go deeper
Recall the idiom itself: pass a constant template with %s placeholders and the values as extra arguments, rather than building the finished string yourself. Know that it matters most for debug calls that are usually switched off.
Explain the mechanism: msg and args are stored on the record and joined by getMessage() only when a handler formats it, while an f-string is evaluated unconditionally at the call site. Mention that arguments are still evaluated eagerly.
Show why the stable template matters on a real stream - grouping and counting events by message shape - and know how to protect a hot path with an isEnabledFor guard when the argument itself is expensive to compute.
Own the convention: one message style across the codebase, enforced by a lint rule, with constant templates so events remain groupable, and a clear rule that user-supplied text is an argument and never part of the template.
### What the two forms actually do ```python logger.debug(f"row {key} rejected by {rule}") # string built now, always logger.debug("row %s rejected by %s", key, rule) # template + args, joined later ``` The f-string is an expression: Python evaluates it, produces a finished `str`, and hands that string to `debug()`. The work is done before the logging module has any say. In the second form, `Logger.debug(msg, *args)` puts `msg` and `args` on the `logging.LogRecord` unchanged, and the interpolation is performed by `record.getMessage()`, which does `msg % args` - and that is called only when a handler's formatter is actually rendering the record for output. ### The cost argument Two savings stack. First, if the call is below the logger's effective level, the method returns before any record exists, so `msg % args` never runs. Second, even when a record is created, the interpolation happens once per emitting handler rather than at the call site. On a cold path this is noise. On a hot loop - a per-row debug line in a log-ingest pipeline processing millions of records - a `logger.debug()` that is filtered out becomes a level comparison and a return, while the f-string version pays full string construction on every single row before the module gets to drop it. That is the case the idiom exists for, and it is why third-party linters ship a rule flagging f-strings inside logging calls. ### The argument that matters more in practice The template survives. `record.msg` remains `"row %s rejected by %s"` no matter which values were passed, so every occurrence of that event shares one identifying shape while the varying parts stay in `record.args`. Anything that consumes records downstream - a formatter, a filter, a handler that serializes fields - can group, count and compare by that shape. With an f-string, a million occurrences are a million distinct strings and the shape has to be recovered by guesswork. There is a security flavour of the same point. Values interpolated by the logging module go in as data; a template that a caller can influence is a formatting hazard, and user text baked into the message itself can carry newlines or lookalike prefixes that make a single event read as several log lines. Keeping the template a constant literal keeps the varying part clearly the varying part. ### The caveat everyone forgets Deferred interpolation is not deferred **evaluation**. In `logger.debug("cost %s", compute_cost())`, the call to `compute_cost()` is an ordinary argument expression and runs before `debug()` is entered, filtered or not. Lazy `%` args save the formatting, never the computation. When the argument itself is expensive, guard the whole call: `if logger.isEnabledFor(logging.DEBUG): logger.debug("cost %s", compute_cost())`. ### Details worth knowing Only `%`-style placeholders work for a logging call's arguments - the module hard-codes `msg % args`. The `style` parameter of `logging.Formatter` selects `%`, `{` or `$` for the **format string** of the output line (the part with `%(levelname)s` and friends), not for your message. Mixing them up - writing `logger.info("row {}", key)` - produces the literal braces, because nothing ever calls `str.format`. A single mapping argument is special-cased: `logger.info("row %(key)s", {"key": k})` interpolates from the dict rather than treating it as one positional value. If the placeholders and arguments do not match - a stray literal `%` in a message with args, or the wrong count - the failure surfaces when the record is formatted, and the module prints a `--- Logging error ---` block to stderr instead of raising into your call path. So a formatting bug in a rarely-hit ERROR line can sit undiscovered until the day that line fires. ### When an f-string is fine When there is nothing to defer and nothing to group: a one-off startup banner, a message assembled at CRITICAL, a script. The habit is still worth keeping, because a message written lazily can be dropped into a hot path later without a rewrite, and because a codebase where every log call uses one form is a codebase where a lint rule can hold the line.
- Does lazy %-style formatting also avoid evaluating the arguments you pass?No. Arguments are ordinary expressions evaluated before the logging method is entered, so `logger.debug("cost %s", compute())` runs `compute()` even when DEBUG is filtered out. Only the `msg % args` interpolation is deferred. When the value itself is expensive, wrap the call in `if logger.isEnabledFor(logging.DEBUG):`, or pass an object whose `__str__` does the work so the cost lands inside formatting.
- Why does logger.info("row {}", key) print literal braces?Because the logging module always interpolates with `%`: `record.getMessage()` computes `msg % args` and knows nothing about `str.format`. The `style` argument of `logging.Formatter` chooses `%`, `{` or `$` for the *output line's* format string, not for your message template. If you want brace style in messages you must supply your own record or message class; the plain call will not do it.
- What happens if the placeholders and the arguments do not match?The mismatch is not detected at the call. It surfaces when a handler formats the record, and the module catches it and writes a `--- Logging error ---` traceback to stderr rather than propagating into your code, so logging never crashes the application. The practical risk is that a broken format string on a rare ERROR path stays hidden until that path fires in production.
You hand the printer a stencil and a pot of ink separately; the two are only combined if a page is actually printed.
saying these in an interview costs you the question
- f-strings and %-args cost exactly the same
- Lazy args also defer evaluating the arguments
- logger.info accepts str.format braces
- A bad format string raises at the call site
- The message template is irrelevant downstream