String formatting — f-strings, .format(), and the format spec mini-language
Python has three ways to build a string from variables — % formatting, .format(), and f-strings — and one format spec mini-language shared by the last two that controls decimals, padding, alignment, and thousands separators.
3 min read
Three ways to put a variable into a string
name, price = "mug", 12.5
"Item: %s, price: $%.2f" % (name, price) # old-style — % operator
"Item: {}, price: ${:.2f}".format(name, price) # .format() — call a method
f"Item: {name}, price: ${price:.2f}" # f-string — inline, since Python 3.6All three produce the exact same string, "Item: mug, price: $12.50". f-strings are the modern default — the variable sits directly inside the string at the point it's used, instead of being passed positionally to a separate call and matched up by position or {} order, which is easy to get wrong once there are more than two or three values. %-formatting still shows up in older codebases and in a few APIs (like the logging module, covered in the logging lesson) that specifically want deferred, lazy formatting — but for new code, reach for an f-string.
f-strings evaluate real Python expressions, not just variable names
items = ["mug", "pen", "notebook"]
f"You have {len(items)} items" # "You have 3 items"
f"Total: ${sum([12.5, 3, 8]):.2f}" # "Total: $23.50"
f"{'yes' if len(items) > 2 else 'no'}" # "yes" — a full ternary expression, inlineThe curly braces don't just hold a variable name — anything between { and } is a real Python expression, evaluated at the point the string is built: function calls, arithmetic, indexing, even a conditional expression. This is what makes f-strings strictly more capable than %-formatting, which can only substitute already-computed values.
The format spec: everything after the :
pi = 3.14159265
f"{pi:.2f}" # "3.14" — 2 decimal places
f"{pi:10.2f}" # " 3.14" — padded to width 10, right-aligned (default for numbers)
f"{1000000:,}" # "1,000,000" — thousands separator
f"{0.856:.1%}" # "85.6%" — as a percentage
f"{'hi':<10}|" # "hi |" — left-align, width 10
f"{'hi':^10}|" # " hi |" — center-align, width 10
f"{42:05d}" # "00042" — zero-padded to 5 digitsEverything after the : is the format spec — a small, dense mini-language for controlling width, alignment (< left, > right, ^ center), padding character, decimal precision, thousands separators, and percentage/scientific notation. It's the same mini-language behind both f"{value:spec}" and "{:spec}".format(value) — f-strings didn't invent a new formatting system, they just made the existing one easier to reach for.
The = debug specifier: printing an expression alongside its value
count = 7
f"{count=}" # "count=7" — auto-shows the source text AND the value
f"{count * 2=}" # "count * 2=14"Added in Python 3.8, {expr=} expands to the literal source text of expr, an =, and then its value — genuinely useful for quick debug prints, since it saves writing f"count={count}" by hand and keeps the label in sync if the expression is ever renamed or edited.
str() vs repr(): what actually runs inside {}
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(x={self.x}, y={self.y})"
p = Point(1, 2)
f"{p}" # "(1, 2)" — uses __str__
f"{p!r}" # "Point(x=1, y=2)" — !r forces __repr__ insteadBy default, f"{p}" calls str(p), which calls the object's __str__ method (covered in the dunder methods lesson) — the same mechanism print() uses. Adding !r forces repr(p) instead, which is meant to be an unambiguous, debug-oriented representation. This matters because objects that don't define __str__ fall back to __repr__ automatically, but not the other way around — defining __repr__ on a class is close to mandatory, __str__ is optional polish on top of it.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does `f"{pi:.2f}"` do to the value of pi?
2. What does `f"{count=}"` print, if count is 7?
3. By default, what does `f"{obj}"` call on obj?
4. Which formatting style is most appropriate for new Python code in most cases?