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 as f"...". 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), space for 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
  • typed (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:

  1. Evaluate the expression total in the current scope, producing a float object.
  2. Call format(total, ",.2f"), which delegates to total.__format__(",.2f").
  3. float.__format__ parses the spec string, applies grouping and rounding, and returns a plain str.
  4. 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 with round() and hoping the string representation matches.
  • For debugging, prefer f"{x=}" over print("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.