skip to content

What do !r, !s and = do inside an f-string replacement field in Python?

level: middleimportance: nice to knowfreq 30%

answer

  1. Three parts, split by an exclamation mark and a colon
  2. Conversion happens before the spec
  3. Three letters only, no more
  4. One of them shows quotes and type
  5. Trailing character echoes the source text

basics

~20 s

They sit before the colon. !s calls str(), !r calls repr() and !a calls ascii() on the value before formatting. A trailing = is the debug form: it prints the expression source, an equals sign, then the value's repr.

solid answer

~40 s

A replacement field is `{expression!conversion:spec}`. The conversion is one of three letters — `!s` applies `str`, `!r` applies `repr`, `!a` applies `ascii` — and it runs **before** the format spec, so the spec then applies to the resulting string; that is why `f"{value!r:>30}"` right-aligns a repr, and why `f"{value!r:.2f}"` raises `ValueError`. `!r` is the one you actually reach for, because a repr shows quotes and type structure and so distinguishes `"5"` from `5` in a log line. The trailing `=`, added in 3.8, is the self-documenting form: `f"{rate=}"` yields `rate=0.07123`, echoing the expression text verbatim — whitespace included, so `f"{rate = }"` keeps the spaces. It defaults to `repr`, but supplying a spec switches it to normal formatting: `f"{rate=:.2%}"` gives `rate=7.12%`. The `=` form exists only in f-strings; `str.format` does not understand it.

code

python · 7 lines
python
value = "5"
print(f"{value}  vs  {value!r}")     # 5  vs  '5'
print(f"[{value!r:>10}]")            # spec pads the repr
try:
    print(f"{1.5!r:.2f}")            # repr made it a str first
except ValueError as exc:
    print("ValueError:", exc)

go deeper

for a junior

Recognise !r when you read it and know it prints the repr — quotes and all — which is what you want in an error message. Knowing that f"{x=}" prints both the name and the value is enough at this level.

for a middle

Explain the field's three parts and the ordering: conversion first, then spec applied to the resulting string. Be ready to say why f"{x!r:.2f}" fails and why the debug form defaults to repr.

for a senior

Show taste, not trivia: !r in every exception message that quotes untrusted input, so the value's type and boundaries are visible when someone reads the incident later. Treat debug fields as scratch code that a linter should catch before it merges.

for a principal

The angle you own is diagnostic quality — whether error text across a codebase reliably distinguishes an empty string from a missing value. That is a convention plus enforcement, not a preference, and it pays off in every triage the team runs.

### Anatomy of a replacement field A field inside an f-string has three parts, and only the first is mandatory: ``` { expression !conversion :format_spec } ``` The conversion is a single letter after `!`, and there are exactly three of them. * `!s` applies the built-in `str` — which is what happens anyway when no conversion is given and the type has no special `__format__`, so it is rarely written. * `!r` applies the built-in `repr`. * `!a` applies the built-in `ascii`, which is `repr` with every non-ASCII character escaped — useful when you need to see exactly which Unicode character is in a string, or when the output must survive an ASCII-only channel. ### Why !r is the one that matters A `str` is the friendly form; a `repr` is the unambiguous one. `f"{value}"` on the text `5` and on the integer `5` both print `5`, and a log line that cannot tell those apart is a log line that will cost you an afternoon. `f"{value!r}"` prints `'5'` for the string and `5` for the integer. The same holds for whitespace and empty strings: `''` is visible in a repr and invisible without one. As a rule of thumb, `!r` belongs in diagnostics and error messages, and plain formatting belongs in text a user reads. ### Order of operations, and the error it explains The conversion runs **first**, and its result is always a `str`. The format spec is then applied to that string. Two consequences follow. First, alignment still works: `f"{value!r:>30}"` pads the repr into a thirty-character column, which is how you line up a debug table. Second, numeric specs stop working. `f"{1.5!r:.2f}"` raises `ValueError: Unknown format code 'f' for object of type 'str'`, because by the time `.2f` is consulted the value is the text `1.5`, not a float. Candidates who have not internalised the ordering read that error as a bug in the f-string. ### The self-documenting = form Added in 3.8, a trailing `=` before the optional conversion and spec turns a field into a debug print. `f"{rate=}"` expands to the literal source text of the expression, an `=`, and the value. Three details are worth knowing: 1. **The expression text is echoed verbatim, whitespace included.** `f"{rate = }"` prints `rate = 0.07123`. That is deliberate: it lets you match the padding of surrounding output. 2. **The default conversion is `repr`, not `str`.** `f"{name=}"` on the string `widget` prints `name='widget'` with quotes — exactly what you want while debugging. 3. **Any expression works**, not just a bare name: `f"{gross - tax=}"` prints `gross - tax=` followed by the computed value, which makes it a fast substitute for a scratch variable. Supplying a conversion or a spec overrides the default: `f"{rate=!s}"` uses `str`, and `f"{rate=:.2%}"` formats the value normally, giving `rate=7.12%`. ### The = form is f-string-only This catches people out. `"{x=}".format(x=1)` does not print `x=1`; it raises `KeyError: 'x='`, because `str.format` parses `x=` as the field name and finds no such argument. The `=` form is a compile-time expansion of a *literal*, and there is no source expression for a runtime template to echo. Conversions, by contrast, are shared: `"{v!r}".format(v="a")` works exactly as it does in an f-string. ### Where this shows up in real code Debug fields are a print-statement accelerator, not a logging strategy — they are eager, they embed the source text in the output, and they are meant to be deleted. Their best use is a temporary line while you are narrowing down a bad value; several linters will flag one left behind in committed code, which is the right outcome. `!r`, on the other hand, is permanent: put it in every exception message that quotes a value the user supplied, so the message shows the value's boundaries and type rather than a bare, ambiguous blob of text.

  • Why does f"{value!r:.2f}" raise ValueError when value is a float?
    Because the conversion is applied first and always yields a `str`. After `!r`, the thing being formatted is the text `1.5`, and `.2f` is not a valid spec for a string, so `str.__format__` raises `ValueError: Unknown format code 'f' for object of type 'str'`. Drop the `!r` if you want fixed-point output; keep it only with string-compatible specs such as a width or alignment.
  • When would you choose !a over !r?
    When you need to see or transmit exactly which non-ASCII characters a value contains. `ascii` is `repr` with every non-ASCII code point escaped, so a name that looks identical in two encodings, or a string with an invisible zero-width character, becomes visible as an escape sequence. It is also the safe choice when the output is written to a channel that is ASCII-only.
  • Does the = debug form work with str.format?
    No. It is an f-string-only, compile-time expansion of the literal's own source text, and a runtime template has no source expression to echo. `"{x=}".format(x=1)` raises `KeyError: 'x='` because `x=` is parsed as the field name. The `!s`, `!r` and `!a` conversions, by contrast, are understood by both.

saying these in an interview costs you the question

  • Thinking !r and !s are interchangeable in a log line
  • Expecting a numeric spec to work after !r
  • Believing f"{x=}" uses str rather than repr
  • Claiming str.format supports the = debug form
  • Assuming whitespace around = is stripped
  • Treating debug fields as a substitute for logging

context