Most Python developers use f-strings every day and know about a fifth of what they do.
That’s not a criticism. You learn f"{name}" from one example, it works, and there’s never a
reason to go further — until the day you need a number rounded to two places inside a table
that lines up, and you find out the colon does something you never learned.
This is the other four fifths. What the f actually does, the whole format spec language, how
to make your own classes work with it, and the three jobs an f-string is the wrong tool for.
Everything here runs at a plain Python prompt. No installs. Every output below was run on Python 3.12.3 and pasted from a real session.
The f is an instruction to the compiler
Start with the difference that explains everything else. Run these two lines:
f"{undefined_name}"
"{undefined_name}"
The first raises NameError. The second is just a string that happens to contain braces.
An f-string isn’t a string with a feature attached. It’s a different thing that looks similar.
"{x}" is stored as text. f"{x}" is never stored anywhere — Python compiles it into
instructions that build a string when the line runs.
You can see the instructions:
import dis
def greet(name, n):
return f"{name} has {n}"
dis.dis(greet)
LOAD_FAST 0 (name)
FORMAT_VALUE 0
LOAD_CONST 1 (' has ')
LOAD_FAST 1 (n)
FORMAT_VALUE 0
BUILD_STRING 3
RETURN_VALUE
Load name, format it, load the literal ' has ', load n, format it, build one string from
the three pieces. There’s no call to .format() and no template stored anywhere.
Three things follow, and each one catches people out later:
- A typo inside the braces is a
SyntaxErrorat import time, not a runtime surprise. - The values are read the moment the line runs. You can’t build an f-string now and fill it in later — there’s nothing to fill.
- It’s fast, because there’s no template to parse. We’ll measure that at the end.
An f-string is not a string. It’s a small program that produces a string.
Anything that is an expression goes in the braces
The braces take an expression — anything that produces a value. They don’t take a statement — anything that does something. That’s the whole rule.
>>> f"{2 + 2}"
'4'
>>> f"{'Ada'.upper()}"
'ADA'
>>> f"{[i * i for i in range(4)]}"
'[0, 1, 4, 9]'
A whole list comprehension ran inside a string. There’s no f-string sub-language here — it’s Python. If you can type it at the prompt and get a value back, it can go in the braces.
f"{x = 5}" doesn’t work, because assignment is a statement. Neither does f"{if x: 1}".
The freedom is real, and it’s the easiest way to write a line nobody can read:
# don't
f"Top: {sorted(users, key=lambda u: -u['score'])[0]['name']}"
# do
top = max(users, key=lambda u: u["score"])
f"Top: {top['name']}"
Same output, and the second one sorts once instead of twice. The guideline that holds up: put a lookup or a short call in the braces, put logic on its own line. If you’re counting brackets, you’ve gone too far.
Braces, quotes and backslashes
Three rules cause most of the f-string errors people actually hit. All three fail loudly, at import time, which is the good outcome.
Literal braces are doubled. {{ gives {, }} gives }:
>>> n = 7
>>> f"{{count: {n}}}"
'{count: 7}'
Read it in pairs. Literal open brace, real substitution, literal close brace. Without the
doubling, f"{status: ok}" reads status as a variable and everything after the colon as a
format spec, and you get:
ValueError: Invalid format specifier ' ok' for object of type 'str'
Alternate your quotes. Pick one style for the f-string and the other inside the braces:
user = {"name": "Ada"}
f"{user['name']}"
Python 3.12 lifted this restriction, so f"{user["name"]}" is legal there. On 3.11 and earlier
it’s a SyntaxError. More on that near the end.
Keep backslashes out of the braces. Before 3.12, f"{'\n'.join(names)}" was an error. Pull
the backslash into a variable:
newline = "\n"
f"{newline.join(names)}"
That works on every version.
Everything after the colon is a different language
>>> value = 3.14159
>>> f"{value:.2f}"
'3.14'
The .2f is not Python. You can’t type it at a prompt and get anything back.
A brace has up to three parts, and only the first one is Python:
{expression!conversion:format_spec}
| Part | Example | What it is |
|---|---|---|
| expression | value |
Python |
| conversion | !r |
one of s, r, a |
| format spec | :.2f |
a different language |
Everything after the colon is the format specification mini-language, borrowed from
.format(), which borrowed it from %-formatting, which borrowed it from C. That history is
why it looks like line noise. It’s old, and it’s short because it was designed to be typed a
lot.
Here’s the whole thing. Every part is optional, and the order is fixed:
[[fill]align][sign][z][#][0][width][grouping][.precision][type]
You don’t have to memorise that. You have to know the order is fixed, so you can take a spec apart instead of guessing at it:
>>> f"{1234.5678:>10,.2f}"
' 1,234.57'
| Piece | Part | Means |
|---|---|---|
> |
align | right-align |
10 |
width | in a field ten characters wide |
, |
grouping | comma between thousands |
.2 |
precision | two decimal places |
f |
type | fixed-point |
Now each part in turn.
Width, alignment and fill
Width alone gives you a column:
>>> f"|{'Ada':10}|"
'|Ada |'
>>> f"|{7:10}|"
'| 7|'
Note that strings default left and numbers default right. That’s deliberate — text reads from the left, numbers line up on their last digit — and it’s a good way to get a surprise. State the alignment when it matters:
>>> n = 7
>>> f"|{n:<8}|{n:>8}|{n:^8}|"
'|7 | 7| 7 |'
< left, > right, ^ centred. There’s a fourth, =, for numbers only — it puts the padding
between the sign and the digits, which is what a ledger wants:
>>> f"{7:=+9}"
'+ 7'
Any single character before the alignment becomes the fill:
>>> f"{7:*^9}"
'****7****'
>>> f"{'menu':.<20}"
'menu................'
The order is always fill then align. *^9 is “fill with star, centre, width nine”. ^*9
isn’t a thing.
One misunderstanding worth clearing up: width is a minimum, never a maximum.
>>> f"|{'a very long name':10}|"
'|a very long name|'
The column breaks. If you need a hard cut, that’s precision, below.
Numbers people have to read
>>> revenue = 1234567.891
>>> f"{revenue}"
'1234567.891'
>>> f"{revenue:,.2f}"
'1,234,567.89'
You had to count digits to read the first one. The , groups thousands and .2f fixes the
decimals — separate features, usable alone.
_ does the same job with an underscore:
>>> f"{1234567:_}"
'1_234_567'
Use , for anything a person reads, _ when the output goes back into Python source or a
config file — Python can read 1_234_567 as a number and can’t read 1,234,567.
% multiplies by a hundred and adds the sign:
>>> f"{0.4567:.1%}"
'45.7%'
Watch what you pass in. If something upstream already did the multiply, you’ll get 4567.0%.
That’s a loud failure, which is the good kind.
And the one everyone reports as a bug:
>>> f"{2.675:.2f}"
'2.67'
That’s correct. 2.675 isn’t exactly 2.675 in binary floating point — it’s very slightly
below — so it rounds down. The formatting is being honest about the value it was handed:
>>> f"{0.1 + 0.2}"
'0.30000000000000004'
If you need money arithmetic to behave the way money behaves, use decimal.Decimal. f-strings
format it with the same spec language, so nothing else in your code changes:
>>> from decimal import Decimal
>>> f"{Decimal('0.1') + Decimal('0.2')}"
'0.3'
Signs and zeros
Three sign options, immediately after the alignment:
>>> f"{5:+d} {-5:+d}" # + : always show a sign
'+5 -5'
>>> f"{5:-d} {-5:-d}" # - : negatives only (the default)
'5 -5'
>>> f"{5: d} {-5: d}" # space : a space where the plus would be
' 5 -5'
That third one is the quiet trick. A space for positives keeps a column aligned without
shouting + at the reader.
A 0 before the width pads with zeros, and does it inside the sign:
>>> f"{7:03d}"
'007'
>>> f"{-7:04d}"
'-007'
>>> f"{-7:0>4}" # plain fill, for comparison
'00-7'
That last one is wrong for a number, which is exactly why the 0 shorthand exists. Zero
padding is what you want for anything sorted as text:
>>> for i in [1, 9, 10, 99]:
... print(f"INV-{i:05d}")
INV-00001
INV-00009
INV-00010
INV-00099
Sort those as strings and they come out in numeric order. Without the padding, they don’t.
Presentation types
One character each, for integers:
>>> f"{255:b} {255:o} {255:x} {255:X}"
'11111111 377 ff FF'
Add # and you get the prefix Python itself would write, which matters when something is going
to read the output back:
>>> f"{255:#x} {255:#b} {255:#o}"
'0xff 0b11111111 0o377'
Combined with zero padding, that’s how you look at bytes:
>>> data = bytes([0, 15, 255, 16])
>>> " ".join(f"{b:02x}" for b in data)
'00 0f ff 10'
For floats, f is fixed point, e is scientific, and g picks between them and strips
trailing zeros:
>>> f"{0.000012345:g}"
'1.2345e-05'
>>> f"{1234.5:g}"
'1234.5'
Use g when the magnitude varies. Use f when you want the same number of decimal places on
every row, which is what a table nearly always wants.
Precision on a string means something different — it truncates:
>>> f"{'a very long name':.6}"
'a very'
Combine it with a matching width and you get a column that can’t overflow:
>>> for name in ["Ada", "a very long name indeed"]:
... print(f"|{name:<10.10}|")
|Ada |
|a very lon|
Specs you build at runtime
The spec can itself contain replacement fields. Python resolves those first, builds the spec, then applies it:
>>> width, places = 12, 3
>>> f"{1234.5678:{width}.{places}f}"
' 1234.568'
Which is how you size a table to its data:
>>> rows = [("Ada", 91), ("Grace", 88), ("Alan Turing", 95)]
>>> w = max(len(name) for name, _ in rows)
>>> for name, score in rows:
... print(f"{name:<{w}} {score:>3}")
Ada 91
Grace 88
Alan Turing 95
Change the data and it still fits. No magic number in the format string, no second pass to fix the alignment.
Nesting only goes one level deep. In practice that has never been a problem.
= — the debug print you stop writing
>>> user_count = 42
>>> f"{user_count=}"
'user_count=42'
You typed the name once. This arrived in Python 3.8 for exactly that reason: everyone was typing every debug variable twice, and half the time the label drifted after a rename.
It works on any expression, and the text on the left is exactly what you typed, whitespace included:
>>> items = [1, 2, 3]
>>> f"{len(items)=}"
'len(items)=3'
>>> x = 42
>>> f"{x = }"
'x = 42'
It stacks with the spec:
>>> price = 1234.5678
>>> f"{price=:>12,.2f}"
'price= 1,234.57'
One thing that surprises people: bare = uses repr, not str.
>>> name = "Ada"
>>> f"{name=}"
"name='Ada'"
Look at the quotes. That’s the right default when you’re debugging, and it leads directly to the next thing.
!r — the one character that fixes your error messages
Every Python object can produce two strings, for two different readers. str(obj) is for a
person. repr(obj) is for a developer — unambiguous, ideally something you could paste back
into Python.
>>> for v in ["Ada", "", " Ada ", None]:
... print(f"{v!r}")
'Ada'
''
' Ada '
None
Every one of those is distinguishable. Without !r, two of them print as something that looks
like nothing at all.
Which is why this matters in an error message:
raise ValueError(f"bad status: {status}") # 'bad status: ' — was it empty? None? a space?
raise ValueError(f"bad status: {status!r}") # "bad status: ''" — question answered
Make it a habit: if the message is about a value being wrong, use !r. It costs one
character and removes the ambiguity that makes people re-run a failure just to find out what
the value was.
There’s also !a, which is repr with non-ASCII escaped:
>>> f"{'café'!a}"
"'caf\\xe9'"
You’ll want it about once a year, when something downstream can’t handle non-ASCII and you need
to see which character is causing it. !s exists too and is the default, so you never need to
write it.
__format__ — the layer that explains everything above
Here’s the machinery under every f-string you’ve written. When Python formats {value:spec},
it calls:
type(value).__format__(value, spec)
That’s it. The spec is passed through as a plain string, and the object decides what to do with it. You can watch it arrive:
class Spy:
def __format__(self, spec):
return f"spec={spec!r}"
>>> f"{Spy()}"
"spec=''"
>>> f"{Spy():>10,.2f}"
"spec='>10,.2f'"
There’s no central format engine. int, float, str, Decimal and datetime each bring
their own. That’s why they understand ,.2f.
The one your class inherits, object.__format__, accepts an empty spec and returns str(self).
Give it anything else and it raises:
TypeError: unsupported format string passed to Point.__format__
Fixing that is three methods, and the interesting one doesn’t parse anything:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Point({self.x!r}, {self.y!r})"
def __format__(self, spec):
if not spec:
return str(self)
return f"({self.x:{spec}}, {self.y:{spec}})"
It hands the spec down to the numbers, which already know what to do with it. Two lines, and
Point supports the entire spec language:
>>> p = Point(3.14159, 2.71828)
>>> f"{p:.2f}"
'(3.14, 2.72)'
>>> f"{p:>8.1f}"
'( 3.1, 2.7)'
That delegation is the whole trick. If your object wraps numbers, hand the spec to the numbers.
If you only ever need f"{p}", define __str__ and __repr__ and skip __format__ — the
inherited one falls back to str for an empty spec. Dataclasses write __repr__ for you but
not __format__, so a spec still fails there.
The f-string doesn’t format anything. It hands the spec to the object, and the object does the work.
Dates use a completely different spec, for a good reason
>>> from datetime import datetime
>>> now = datetime(2026, 9, 2, 14, 5, 9)
>>> f"{now:%Y-%m-%d %H:%M}"
'2026-09-02 14:05'
>>> f"{now:%A, %d %B %Y}"
'Wednesday, 02 September 2026'
That looks nothing like the mini-language, and the previous section explains why:
datetime.__format__ ignores it completely and passes the spec straight to strftime. So the
whole strftime vocabulary works inside an f-string.
The codes you’ll actually use: %Y year, %m month, %d day, %H hour, %M minute, %S
second, %B month name, %A weekday, %p AM/PM. Note that %M is minute and %m is month —
case matters, and getting it wrong gives you a plausible-looking wrong date.
The practical payoff is filenames that sort:
>>> f"report-{now:%Y%m%d-%H%M}.csv"
'report-20260902-1405.csv'
timedelta has no __format__ override, so the spec language doesn’t apply to durations. Pull
numbers out and format those instead:
>>> from datetime import timedelta
>>> d = timedelta(hours=2, minutes=5)
>>> total = int(d.total_seconds())
>>> f"{total // 3600}h {total % 3600 // 60:02d}m"
'2h 05m'
That’s the general shape whenever an object doesn’t understand a spec.
Long strings, and the bug that doesn’t raise anything
Adjacent string literals join at compile time, so this is the cheap way to break a long line:
message = (
f"Hello {name}, "
f"your order {order_id} shipped "
f"and should arrive by {eta}."
)
Every piece needs its own f. Miss one and nothing crashes:
>>> a = 1
>>> (f"value {a} "
... "and {a} again")
'value 1 and {a} again'
Read that output. The second {a} came through as literal text. This is the most common quiet
f-string bug there is — no error, just a wrong string that can travel a long way before anyone
notices.
Also watch the spaces at the joins. Put the space at the end of each line, consistently, and you’ll stop losing them.
For real multi-line output, use triple quotes — and textwrap.dedent if the source is indented
inside a function, or every line comes out with the indentation attached:
import textwrap
return textwrap.dedent(f"""
Order {order_id}
Customer: {name}
Total: {total:,.2f}
""")
Three jobs an f-string is wrong for
An f-string is evaluated where it’s written, once. Nothing is left over. Any job that needs “build the shape now, fill it in later” is not an f-string job, and there are three of those.
A reusable template
template = "Hello {name}, you scored {score}"
template.format(name="Ada", score=91)
The string stays data. You can put it in a config file, read it from a database, or let a user supply it. An f-string can do none of that.
The same reason rules out translation: extraction tools work by pulling literal strings out of your source, and an f-string has no literal to pull — by the time it exists, it’s already filled in.
Logging
This one looks harmless. Run it and watch the counter:
import logging
logging.basicConfig(level=logging.WARNING)
log = logging.getLogger("demo")
calls = {"n": 0}
class Record:
def __str__(self):
calls["n"] += 1
return "the expensive text form"
r = Record()
log.debug(f"record: {r}")
print("after f-string debug:", calls["n"])
log.debug("record: %s", r)
print("after %s debug: ", calls["n"])
after f-string debug: 1
after %s debug: 1
The level is WARNING, so both lines were thrown away. The f-string one called __str__
anyway. The %s one didn’t — logging stores the template and the value separately, and only
substitutes if a handler actually wants the message.
The raw speed gap is smaller than people claim. Two hundred thousand suppressed debug calls, median of five runs on one machine:
f-string 0.052s
%s 0.033s
About a tenth of a microsecond per call. The cost that matters is the other one — if the value
is a database row or anything with a __repr__ that walks a structure, you’re doing that work
every time and discarding it.
The rule that holds up: %s in library code and anything that logs in a loop, f-strings in
application code where the values are cheap. Both are defensible; being inconsistent isn’t.
Most linters have a rule for this and turning it on settles the argument. If you want both,
guard the expensive case:
if log.isEnabledFor(logging.DEBUG):
log.debug(f"record: {expensive_summary(record)}")
SQL
Set up a table and query it with an f-string:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("create table users(name text, role text)")
con.executemany("insert into users values (?,?)",
[("Ada", "admin"), ("Grace", "user"), ("Alan", "user")])
name = "Ada"
con.execute(f"select * from users where name = '{name}'").fetchall()
[('Ada', 'admin')]
It works. Now change one line:
name = "x' OR '1'='1"
con.execute(f"select * from users where name = '{name}'").fetchall()
[('Ada', 'admin'), ('Grace', 'user'), ('Alan', 'user')]
Every row. Print the query and it’s obvious:
select * from users where name = 'x' OR '1'='1'
The value carried a quote. That quote closed the string the query was building, and everything
after it became part of the query instead of part of the value. You didn’t ask for an OR —
the data supplied one.
The fix is to let the driver do it:
>>> con.execute("select * from users where name = ?", (name,)).fetchall()
[]
No rows, which is correct — there’s no user with that peculiar name. The ? is a
placeholder, not string substitution. The database receives the query and the value as two
separate things, so the value is never parsed as SQL and there’s nothing to escape.
Different drivers use different styles — ? for sqlite3, %s for psycopg and MySQL, :name
for named parameters — but the principle is identical.
An f-string near SQL isn’t automatically wrong. Values must be parameters; identifiers can’t be, because no database lets you parameterise a table or column name. So if you need a dynamic column, an f-string is the mechanism and the safety is yours to supply:
SORTABLE = {"name", "created_at", "score"}
def query(sort_by):
if sort_by not in SORTABLE:
raise ValueError(f"cannot sort by {sort_by!r}")
return f"select * from users order by {sort_by}"
The allow-list does the work. Never sanitise by escaping quotes yourself — that’s a game you lose eventually.
This isn’t really about SQL. It’s the same mistake wherever you build one language out of another’s untrusted text: HTML, shell commands, paths, regex. Text meant as data got read as instructions.
Parameterise values. Allow-list identifiers. Never build a query by formatting user input into it.
The one that isn’t a limitation
People often add “user input” to this list. It doesn’t belong there:
f"Hello {user_name}" # fine
user_name is a value being formatted, not code being run. The danger is the other direction —
letting a user supply the template. .format() on an untrusted template can walk attributes
and reach things you didn’t mean to expose. f-strings can’t be used that way at all, because
there’s no runtime template to hand over. On this specific point, f-strings are the safer tool.
What Python 3.12 changed
d = {"key": "value"}
f"{d["key"]}"
On 3.12 and later that prints value. On 3.11 and earlier it’s a SyntaxError. Check with
python3 -VV before concluding anything about your own code.
The restrictions existed because f-strings weren’t parsed by Python’s real parser. The compiler pulled the string out, did text manipulation on the part between the braces, and handed that to the parser separately. The rules felt arbitrary because they were consequences of the implementation, not decisions anyone made. PEP 701 rewrote f-strings to use the normal parser, and the restrictions went with it.
Now legal, 3.12 and later only:
f"{d["key"]}" # reusing the same quote
f"{"\n".join(names)}" # backslashes in the expression
f"{f"{f"{d["key"]}"}"}" # nesting as deep as you like, and please don't
total = f"{
sum(item['price'] for item in order) # multiple lines, and comments
:,.2f
}"
Error messages got better too, because the parser now knows where it is.
This is a compatibility decision, not a style one, and it has one question in it: what’s the oldest Python your code has to run on? If you control the environment and it’s 3.12 or later, use quote reuse where it reads better. If your code is a library or runs anywhere you don’t control, keep alternating quotes — it works everywhere including 3.12, and nothing about it is worse.
Speed, and why it’s the least interesting reason
Measure it yourself. This takes about a second:
import timeit
setup = "name='Ada'; n=7"
for label, stmt in [
("f-string", "f'{name} has {n}'"),
("format", "'{} has {}'.format(name, n)"),
("percent", "'%s has %s' % (name, n)"),
("concat", "name + ' has ' + str(n)"),
]:
t = timeit.timeit(stmt, setup=setup, number=1_000_000)
print(f"{label:9} {t:.3f}s")
Run it once and you’ll get something. Run it seven times and you get something more honest. Python 3.12 on an ordinary laptop, seven repetitions of a million operations each:
| best | median | worst | |
|---|---|---|---|
| f-string | 0.133s | 0.149s | 0.230s |
.format() |
0.199s | 0.212s | 0.278s |
% |
0.145s | 0.161s | 0.176s |
| concatenation | 0.159s | 0.173s | 0.190s |
Two different things are true here, and they’re worth separating.
.format() is reliably the slowest, by a margin that survives the noise. That part has a real
explanation behind it: it has to look up the method and parse the template at runtime, every
time the line runs, while the f-string compiled into instructions that build the string
directly.
f-strings and % are basically tied. Across those seven runs the f-string was fastest four
times and % was fastest three times. Run the script once, as almost everyone does, and you
walk away certain about an ordering that flips depending on when you ran it.
Now look at the scale anyway. That’s a million operations, so even the honest gap against
.format() is about 0.06 microseconds per string. To save one millisecond you need to format
around fifteen thousand of them.
So: f-strings are fast, and the speed is almost never the reason to use one. Use them because the values sit where they’re read. Take the speed as a bonus you didn’t have to think about.
It does matter in two recognisable places — formatting in a tight loop, and suppressed logging, where the problem isn’t speed but discarded work. Everywhere else the bottleneck is the database, the network, the JSON parse, or an algorithm doing more work than it needs to. If you think formatting is slow in your code, profile that code rather than rewriting your f-strings on a hunch.
The short version
If you remember five things:
- An f-string is compiled, not stored. There’s no template, which is why it can’t be reused and why it’s fast.
- Everything after the colon is a different, older language with a fixed word order.
,.2fis the most useful five characters in it. f"{x=}"for debugging,!rfor anything that reports a value that went wrong.- The spec is handed to
type(value).__format__. That’s whydatetimeuses%Yand why your own class can join in with two lines. - Values in SQL are parameters, never interpolation. The one that bites is always the quote you didn’t expect.
The rest is width and alignment, and you can look those up.