f-strings are the right default. This article is about the four places they are not, because a blanket “modernise all string formatting” refactor reaches every one of them, and three of the four fail silently.
1. Logging
logger.info("payment %s failed for user %s", payment_id, user_id) # keep
logger.info(f"payment {payment_id} failed for user {user_id}") # regression
The logging HOWTO documents passing arguments separately, and the reason is not
only laziness about formatting. A LogRecord carries msg (the template) and
args (the values) as separate attributes. A structured handler emits them as
separate fields. Your aggregator groups by the template.
Collapse them and three things happen at once:
-
Formatting becomes unconditional.
logger.debug(f"{payload!r}")callsrepron every request whether or not DEBUG is enabled. - Grouping dies. One template with a million values becomes a million distinct messages. Alerting rules built on “count of this message” stop working, quietly, with no error anywhere.
- The template stops being a literal. Which matters for the next section.
Ruff’s G004 (“logging statement uses f-string”) exists for this. If you
adopt one lint rule from this article, adopt that one.
The %-style is not legacy here. It is the interface.
💡logger.info("hello %s", name) uses %-formatting, but logging also supports str.format and string.Template styles via style=. If deferred formatting is the point, why not use logger.info("hello {}", name) with style="{"?
click to reveal
You can — but the style= parameter is set on the Formatter, not on the call, and it controls the format string of the handler’s output line, not the message template.
Concretely: logging.Formatter(fmt="{asctime} {message}", style="{") changes how the handler assembles its output. The message template you pass to logger.info is always interpolated with %, by LogRecord.getMessage, because record.msg % record.args is hard-coded there.
To get brace-style message templates you need a wrapper class that defers __str__, which is documented in the logging cookbook and which almost nobody does. So in practice: %-style in the call, whatever style you like in the Formatter. The two are unrelated settings that share a name.
2. SQL and shell
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'") # injection
cursor.execute("SELECT * FROM users WHERE name = %s", (name,)) # parameterised
The second %s is not Python formatting. It is the DB-API driver’s own
placeholder, and the driver quotes and escapes the value on the far side of the
wire. A well-meaning “modernise the %-formatting” pass that rewrites it as an
f-string converts a safe query into an injectable one, and both versions return
the same rows in every test.
Same story for shell: subprocess.run(["convert", name, "out.png"]) has no
shell to inject into; os.system(f"convert {name} out.png") does.
3. i18n and templates
An f-string is evaluated eagerly, at the literal site, in the source file. That means it:
-
cannot be extracted by
xgettextor any other message extractor, - cannot be stored in a database, a config file, or a translation catalogue,
- cannot be selected at runtime based on the user’s locale.
gettext("Hello, %(name)s") % {"name": name} can do all three, which is why
translation systems are built on deferred formatting. If a string is ever going
to be seen by a user in more than one language, it is not an f-string.
The general principle: f-strings couple the template to the data. That is exactly what you want for a debug line and exactly what you cannot have for a template.
4. __repr__ of untrusted data
def __repr__(self) -> str:
return f"User({self.name})" # calls str()
return f"User({self.name!r})" # calls repr()
The first form is a bug in a __repr__. repr is supposed to be
unambiguous — quoted, escaped, round-trippable if possible. A name of
", is_admin=True, x=" renders into something that looks like a different
object entirely. Add !r to every interpolated value in a __repr__, without
exception. (This is what f"{x=}" does by default, and why.)
💡You are reviewing a __repr__ that renders a 50 000-element list field. It uses !r. What else is wrong with it, and what does the stdlib do about the same problem?
click to reveal
Size, and the fact that __repr__ is called in contexts where you cannot afford it.
A repr is invoked by the debugger on every step, by the REPL on every evaluation, by logging when a record is formatted, and by pytest when an assertion fails. Producing a megabyte of text in any of those turns a debugging session into a hang, and an assertion failure into an unreadable wall.
The stdlib’s answer is reprlib: reprlib.repr(obj) truncates containers to a few elements and strings to a few characters, and @reprlib.recursive_repr() replaces a self-referential nested call with ... instead of blowing the stack. dataclasses uses recursive_repr on its generated __repr__ for exactly that reason.
The practical rule for a big field: render its length, not its contents — f"User(name={self.name!r}, events=<{len(self.events)} items>)". You get the diagnostic information without the payload.
The type that turns this into a static error
typing.LiteralString (PEP 675, 3.11):
def query(sql: LiteralString) -> Rows: ...
A LiteralString is a string the checker can prove was assembled entirely from
string literals — including via +, .join, and f-strings whose
interpolations are themselves LiteralString. The moment a runtime value enters
the expression, the result is str, not LiteralString, and the call is a
type error.
query("SELECT * FROM users") # ok
query("SELECT * FROM " + table_literal) # ok, both literals
query(f"SELECT * FROM users WHERE id = {uid}") # error: str is not LiteralString
That converts an entire vulnerability class into a compile-time failure — for
the code paths that genuinely are static. Where the query really must be built
from data, the answer is parameters (or, on 3.14+, a t-string API that can see
the seam), not a cast.
The reviewer’s summary
| Context | Use | Because |
|---|---|---|
| Debug output, error messages, ordinary string building | f-string | Value next to label; fastest |
logger.* calls |
%s + args |
Deferred, and record.args is the aggregation key |
| SQL, shell, any interpreter | Parameters / argv list / t-string | The other side does the escaping |
| User-visible text | Deferred template | Extractable, storable, translatable |
__repr__ |
f-string with !r |
Unambiguous by contract |