Python String Formatting
String formatting is how you build human-readable text out of variables and values — inserting a number into a sentence, aligning columns in a report, or controlling how many decimal places a price shows. Python has accumulated three distinct formatting systems over its history, and while all three still work today, one of them (f-strings) is now the idiomatic default. Understanding all three matters because you will encounter each of them in real codebases, and understanding the shared “format spec” mini-language underneath them lets you control output down to the character.
Overview: three ways to format strings
Python’s formatting systems evolved in this order:
- %-formatting — the original, C-`printf`-style syntax using the
%operator. Still common in older code and in logging calls. str.format()— introduced in Python 2.6/3.0, a method-based system using{}placeholders. More powerful and readable than%.- f-strings (formatted string literals) — introduced in Python 3.6 (
PEP 498), written asf"...". They let you embed *expressions* directly inside the string, and they are evaluated at runtime where the literal appears.
All three ultimately rely on the same underlying machinery for formatting individual values: the format spec mini-language, which is the part after a colon inside a placeholder (e.g. {value:.2f}). Internally, formatting a value calls its __format__ method — format(value, spec) is really value.__format__(spec). This is why custom classes can define their own __format__ to support custom format specs.
A key internal detail: an f-string is not a single string object waiting to be filled in later — the Python parser compiles it into bytecode that evaluates each embedded expression and calls format() on the result, then concatenates everything. This is also why f-strings can contain arbitrary expressions (function calls, arithmetic, attribute access) but cannot easily be stored and reused as a “template” the way a str.format() string can — the expressions inside an f-string are bound to the variables in scope at the moment the literal is written, not at some later call time.
Syntax
| Style | Example | Notes |
|---|---|---|
| %-formatting | "%s is %d" % (name, age) |
Uses conversion codes like %s, %d, %f; right operand is a tuple (or single value/dict) |
str.format() |
"{} is {}".format(name, age) |
Placeholders can be positional {0}, keyword {name}, or auto-numbered {} |
| f-string | f"{name} is {age}" |
Expressions evaluated inline; supports the same format specs |
The shared format spec mini-language, used after a colon in both str.format() and f-strings, has this general form:
{value:[[fill]align][sign][#][0][width][,][.precision][type]}
- fill/align — a fill character followed by
<(left),>(right),^(center), or=(pad after sign) - sign —
+always show sign,-only negative (default),spacefor a leading space on positives - width — minimum field width
- , or _ — thousands separator
- .precision — digits after the decimal point for floats, or max characters for strings
- type —
d(int),f(fixed float),e(scientific),%(percentage),s(string),x/o/b(hex/octal/binary)
Examples
Example 1: the three styles side by side
name = "Ada"
age = 36
# %-formatting
print("%s is %d years old" % (name, age))
# str.format()
print("{} is {} years old".format(name, age))
# f-string
print(f"{name} is {age} years old")
Output:
Ada is 36 years old
Ada is 36 years old
Ada is 36 years old
All three produce identical output here. The f-string is the shortest and clearest because the variable names appear directly where they’re used, instead of being separated into an argument list at the end.
Example 2: number formatting with the mini-language
price = 1234.5
quantity = 3
total = price * quantity
print(f"Total: ${total:,.2f}")
print(f"Quantity: {quantity:>5}")
print(f"Progress: {0.4567:.1%}")
print(f"Hex value: {255:#x}")
Output:
Total: $3,703.50
Quantity: 3
Progress: 45.7%
Hex value: 0xff
,.2f adds a thousands separator and rounds to two decimals. >5 right-aligns the quantity in a field 5 characters wide. .1% multiplies by 100, rounds to one decimal, and appends a % sign. #x converts to lowercase hex and includes the 0x prefix.
Example 3: a formatted report using alignment and debug specifiers
items = [("Widget", 3, 9.99), ("Gadget", 12, 24.5), ("Gizmo", 1, 199.0)]
print(f"{'Item':<10}{'Qty':>5}{'Price':>10}")
for name, qty, price in items:
print(f"{name:<10}{qty:>5}{price:>10.2f}")
x = 42
print(f"{x=}")
Output:
Item Qty Price
Widget 3 9.99
Gadget 12 24.50
Gizmo 1 199.00
x=42
Combining < and > alignment with fixed widths produces neatly columned text without manual padding. The last line uses the self-documenting expression feature ({x=}, Python 3.8+), which prints both the expression text and its value — extremely handy for quick debugging prints.
Under the hood
When Python encounters f"{total:,.2f}", it effectively does the following at runtime:
- Evaluate the expression
totalin the current scope, producing a float object. - Call
format(total, ",.2f"), which delegates tototal.__format__(",.2f"). float.__format__parses the spec string, applies grouping and rounding, and returns a plainstr.- The result is concatenated with the surrounding literal text to build the final string.
str.format() works the same way but parses placeholders out of a string that already exists (possibly read from a config file or constant), which is why it’s still preferred when the template itself needs to be reusable or stored separately from the data. %-formatting is different under the hood: it does not call __format__ at all, but uses C-style conversion (closer to printf), which is why its behavior with custom objects and certain edge cases (like a single tuple as the right-hand operand) differs subtly from the other two.
Common Mistakes
Mistake 1: forgetting the tuple wrapper in %-formatting when passing more than one value.
name = "Ada"
age = 36
# Wrong: passing two bare values instead of a tuple
# print("%s is %d" % name, age) # TypeError: not all arguments converted
# Correct: wrap the values in a tuple
print("%s is %d" % (name, age))
Output:
Ada is 36
Without the parentheses, Python treats name as the single formatting argument for %s, and age becomes a second positional argument to print() instead of part of the format operation — but because %d has nothing left to consume, Python raises TypeError: not enough arguments for format string in most cases, or produces a wrong result if there’s exactly one placeholder.
Mistake 2: mismatched or missing placeholders in str.format().
template = "{name} scored {score}"
# Wrong: missing the 'score' key raises KeyError at runtime
# print(template.format(name="Ada"))
# Correct: supply every named placeholder
print(template.format(name="Ada", score=95))
Output:
Ada scored 95
Because str.format() parses the template and data separately, it’s easy for the two to drift out of sync (especially after refactoring). f-strings avoid this class of bug entirely since the variable and the placeholder are written in the same place.
Best Practices
- Prefer f-strings for new code — they’re the fastest of the three at runtime and the most readable, since data and template live together.
- Use
str.format()(or%for logging) when the template string needs to be defined separately from the data, such as a constant loaded from a config file or translation string. - Avoid
%-formatting for new code except in logging calls (e.g.logging.info("%s", value)), where lazy evaluation avoids the cost of formatting a message that may be filtered out. - Use
:,for thousands separators instead of writing manual comma-insertion logic. - Use
:.2f(or similar) for currency and measurements rather than rounding manually withround()and hoping the string representation matches. - For debugging, prefer
f"{x=}"overprint("x:", x)— it’s shorter and self-documenting. - Never build SQL or shell commands with string formatting of any kind — use parameterized queries or the appropriate escaping API to avoid injection vulnerabilities.
- Don’t nest f-strings excessively or cram complex logic inside
{}— if an expression needs more than a simple attribute/index/call, compute it in a variable first for readability.
Practice Exercises
- Exercise 1: Given
temperature = 98.6, print"Temperature: 98.60F"using an f-string with a format spec that always shows exactly two decimal places. - Exercise 2: Given a list of tuples
[("Alice", 92.5), ("Bob", 88.333)], print each name left-aligned in a 10-character field and each score right-aligned in an 8-character field with 1 decimal place. - Exercise 3: Given
count = 1500000, use an f-string to print it with thousands separators, e.g."1,500,000".
Summary
- Python has three formatting systems:
%-formatting (legacy),str.format()(template-based), and f-strings (modern, expression-embedding). - f-strings are evaluated at the point they appear and compiled directly by the parser, making them fast and readable.
str.format()and f-strings share the same format spec mini-language after the colon: fill/align, sign, width, thousands separator, precision, and type.- Formatting a value calls its
__format__method under the hood, so custom classes can define their own formatting behavior. - Prefer f-strings for everyday code; keep
str.format()for reusable templates and%for logging calls.
