Readable code in practice

f-strings, str.format() or %? Choosing a String Formatting Style in Python

Readable code in practice · · Published 2026-11-12

A young man in a light jacket and tie feeding a sheet of paper under the printing head of an upright addressing machine on a wooden desk
An addressing machine at the US Treasury Department in 1918: the same form, a different name and address on each copy. Photo: Harris & Ewing, Library of Congress, public domain.

Few things show the age of a Python codebase as clearly as the way it builds strings. The same line can be written with the % operator inherited from C, with the str.format() method added in Python 2.6 and 3.0, or with an f-string, available since Python 3.6. PEP 8 is silent on the choice; it only discusses quote characters. This guide sets out a default, the few places where that default is the wrong tool, and how to let a linter keep the rule for you.

The same line, three ways

user, total = "Ada", 1234.5

"%s owes %.2f" % (user, total)          # printf-style, the oldest form
"{} owes {:.2f}".format(user, total)    # str.format(), Python 2.6 and 3.0
f"{user} owes {total:.2f}"              # f-string, Python 3.6 (PEP 498)

All three produce Ada owes 1234.50. The difference is in how the line reads. With % and format(), the reader has to match each placeholder with an argument further along the line, counting positions; with an f-string, each expression is in place. That is the argument made in PEP 498, which introduced "literal string interpolation", and it is why f-strings have become the usual default.

The % operator also has a trap that the other two do not. Its right-hand side is a tuple of arguments, so formatting a value that happens to be a tuple fails or misbehaves:

point = (3, 4)
"Point: %s" % point      # TypeError: not all arguments converted during string formatting
"Point: %s" % (point,)   # works, but only if you remember the wrapping tuple

Neither format() nor an f-string has this problem.

What goes inside the braces

f-strings and str.format() share the same format specification mini-language, documented in the string module. After a colon come alignment, width, thousands separators and precision; before it, an optional conversion: !r for repr(), !s for str(), !a for ascii().

f"{price:,.2f}"            # 1,234,567.89
f"{ratio:.1%}"             # 45.7%
f"{name:>10}"             # right-aligned in 10 characters
f"{when:%d %B %Y}"         # a date, using the object's own format codes
f"{path!r}"                # the repr, quotes included: useful in error messages
f"{retries=}"              # 'retries=3', Python 3.8 and later, handy for debugging

Keep the expressions short. An f-string can contain any expression, including function calls and conditional expressions, but a line such as f"{', '.join(sorted(u.name for u in users if u.active))}" hides logic inside a string literal. Compute the value on its own line, give it a name, and interpolate the name. Our article on readable Python code makes the same case for expressions in general.

What changed in Python 3.12

PEP 701, implemented in Python 3.12, gave f-strings a proper grammar. Expressions inside the braces can now reuse the quote character of the enclosing string, contain backslashes, span several lines and include comments. f"{", ".join(names)}" is valid on 3.12 and a syntax error on 3.11. If your project still supports older versions, keep using the other quote character inside the braces; a linter running on a newer interpreter may not warn you.

Python 3.14 added a fourth form, template strings or t-strings (PEP 750). t"Hello {name}" looks like an f-string but evaluates to a Template object that keeps the literal parts and the values separate, so that a library can escape or process the values before combining them. It is a tool for library authors handling HTML, SQL or shell fragments, not a replacement for everyday f-strings.

Where the default is wrong

Logging calls

The logging module formats a message only when a handler is going to emit it. Passing the template and the arguments separately keeps that benefit:

logger.debug("Loaded %d rows from %s", len(rows), path)

An f-string is formatted before the call, even when the debug level is switched off and the message is thrown away. The cost is usually small, but the separate form has a second advantage: the unformatted template stays available to handlers, and some error-tracking services use it to group occurrences of the same message. The Logging HOWTO uses this %-style form throughout. This is the one place where % placeholders remain the normal style in modern code.

Translated text

With gettext, the string passed to _() is the key looked up in the translation catalogue. An f-string is evaluated first, so _(f"Hello {name}") looks up "Hello Ada", which no catalogue contains, and extraction tools cannot collect a usable template from it. Write _("Hello {name}").format(name=name) instead, so that translators see the placeholder and can move it within the sentence.

Templates you did not write

When the template comes from a configuration file, a database or a user, an f-string is impossible: it exists only in source code. str.format() works, but it can read attributes and items of the objects passed to it, so a hostile template such as "{user.__class__}" can reach data you did not mean to expose. For templates written by people outside your team, string.Template is the safer choice: it supports only $name substitution, and the Python documentation names internationalisation as one of its main uses.

SQL and shell commands

No formatting method is safe for building a query from values. Pass them as parameters and let the driver quote them: cursor.execute("SELECT * FROM users WHERE name = ?", (name,)) with sqlite3, or the placeholder style of your database library. The same goes for subprocess: pass a list of arguments rather than a formatted command line.

Quotes, prefixes and the formatter

PEP 8's only rule on strings concerns quotes: single and double quotes are equivalent, so pick one and stick to it, but use the other kind when a string contains a quote character, to avoid backslashes; for triple-quoted strings, use double quotes, as PEP 257 asks for docstrings. If you run Black or ruff format, the choice is made for you: both normalise to double quotes unless that would require more escaping, as described in our article on the Black formatter. Neither rewrites a % expression into an f-string; that is a job for the linter.

Letting the linter keep the rule

Both main linters can flag the older forms and the logging mistake. Here is what they reported on 12 November 2026 for a small file containing one example of each, with ruff 0.17.4 (rule families UP, G and F selected) and pylint 4.1.3:

ruff:
G004  Logging statement uses f-string
UP031 Use format specifiers instead of percent format
UP032 [*] Use f-string instead of `format` call
F541  [*] f-string without any placeholders

pylint:
W1203: Use lazy % formatting in logging functions (logging-fstring-interpolation)
C0209: Formatting a regular string which could be an f-string (consider-using-f-string)
W1309: Using an f-string that does not have any interpolated variables (f-string-without-interpolation)

Ruff can rewrite format() calls into f-strings automatically with --fix; its fix for % expressions, which converts them to format() calls, is classed as unsafe and must be requested explicitly. The UP rules are not in ruff's default selection, so add them in pyproject.toml; our guides to ruff and to flake8 and pylint show where. On a large codebase, convert in one dedicated commit with no other changes, so that reviewers can check it quickly.

A rule you can write down

  1. Use f-strings for strings built in code, with simple expressions inside the braces.
  2. In logging calls, pass a %-style template and the arguments separately.
  3. Use str.format() for templates stored outside the code and for translated messages, and string.Template for templates written by users.
  4. Never format values into SQL or shell commands.
  5. Enable the matching linter rules so that the rule does not depend on code review.

Sources