Skip to content
← All articles

Where f-strings are the wrong answer

Four places where 'modernise all string formatting' makes the code slower, less observable, untranslatable, or injectable — and the type that turns injection into a static error.

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}") calls repr on 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 xgettext or 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