Python

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.

Beginner

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.6

All 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, inline

The 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 digits

Everything 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__ instead

By 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?