String Formatting
Turning values into readable text is one of the most common things code does — Python gives you three overlapping ways to do it, and one clearly modern favorite.
String formatting is the process of building a string by substituting values into a template. Python has accumulated three formatting systems over its history: the old % operator (inherited from C-style printf), the str.format() method (introduced in Python 3.0/2.6), and f-strings (formatted string literals, introduced in Python 3.6). All three share a common 'format specification mini-language' for controlling width, alignment, precision, and numeric presentation, but f-strings are the modern default for almost all new code.
Why Formatting Matters
Concatenating strings with + gets unwieldy fast, especially once numbers are involved: you have to manually call str() on every non-string value, and aligning columns of output by hand is tedious and error-prone. A formatting system lets you write a template once, mark where values go, and optionally control how those values are rendered (decimal places, padding, thousands separators) — all in one readable expression.
name = "Ada"
score = 97.5
# Concatenation: verbose, and you must convert types yourself
msg = "Student " + name + " scored " + str(score) + "%"
# f-string: reads like the sentence you mean
msg = f"Student {name} scored {score}%"f-strings: Syntax and Evaluation
An f-string is a string literal prefixed with f (or F) containing {expression} replacement fields. Each field is evaluated as a real Python expression at the point the literal runs, then converted to a string and inserted. Because the expression is evaluated live, f-strings can call functions, index containers, and access attributes directly inside the braces.
Inside an f-string you can use either single or double quotes for a nested string literal as long as they differ from the f-string's own outer quotes (Python 3.12 relaxed this further, allowing the same quote character to be reused).
user = {"name": "Grace", "age": 36}
items = [1, 2, 3]
print(f"{user['name']} is {user['age']} years old")
print(f"Sum: {sum(items)}")
print(f"Upper: {user['name'].upper()}")The Format Specification Mini-Language
After the expression, an optional : introduces a format spec: {value:spec}. The mini-language is shared by f-strings, str.format(), and the built-in format() function. Its general shape is [[fill]align][sign][#][0][width][,][.precision][type] — most fields are optional and you only specify what you need.
{value:<10} left-align in a 10-char field
{value:>10} right-align in a 10-char field
{value:^10} center in a 10-char field
{value:+} always show sign for numbers
{value:,} thousands separator
{value:.2f} fixed-point, 2 decimal places
{value:.2%} percentage with 2 decimals
{value:x} hexadecimal (lowercase)
{value:08d} zero-padded integer, width 8pi = 3.14159265
print(f"{pi:.2f}") # 3.14
print(f"{1234567:,}") # 1,234,567
print(f"{0.856:.1%}") # 85.6%
print(f"{255:#x}") # 0xffNumeric Formatting in Depth
The type character at the end of a spec tells Python how to interpret a number: d for integer, f for fixed-point float, e/E for scientific notation, % for percentage (multiplies by 100 and appends %), x/X/o/b for hex/octal/binary. .precision means 'digits after the decimal point' for floats but 'maximum characters' for strings.
Rounding in the f type uses round-half-to-even (banker's rounding), the same rule the built-in round() uses — f"{2.5:.0f}" gives '2', not '3', because binary floating-point representation and the rounding rule interact this way.
value = 1234.5678
print(f"{value:.1f}") # 1234.6 (rounded, half-to-even on ties)
print(f"{value:e}") # 1.234568e+03
print(f"{value:,.2f}") # 1,234.57
print(f"{-5:+d}") # -5
print(f"{5:+d}") # +5Alignment and Width
Width reserves a minimum number of characters for the field; fill (any single character, default space) pads it, and align (< left, > right, ^ center, = pads after the sign for numbers) controls where the padding goes. Strings default to left-aligned; numbers default to right-aligned.
for name, score in [("Ada", 97), ("Grace", 8), ("Linus", 100)]:
print(f"{name:<8}{score:>5}")
# Ada 97
# Grace 8
# Linus 100print(f"{'title':*^20}") # ********title*******
print(f"{-42:0=6}") # -00042 (sign, then zero-padding)The str.format() Method
str.format() predates f-strings and applies the same mini-language to a template string, with {} placeholders filled by positional or keyword arguments passed to .format(). It is still useful when the template itself must be a variable — for example, a message loaded from a config file or translation catalog — since an f-string's expressions are baked in at the literal's own source location and cannot be supplied later.
template = "{name} scored {score:.1f}%"
print(template.format(name="Ada", score=97.456))
# Positional and reused arguments
print("{0} and {1}, {0} again".format("a", "b"))
# Attribute and index access work here too
point = {"x": 3, "y": 4}
print("({0[x]}, {0[y]})".format(point))Old-Style % Formatting (Overview)
The % operator formats strings using C printf-style conversion specifiers (%s, %d, %f, and so on), applied as template % values. It predates both str.format() and f-strings and still appears in older code and in some logging APIs. It is generally considered legacy for new code: it is easy to get positional order wrong, it handles only a single value or a tuple/dict of values, and error messages are less clear than the alternatives.
A literal % character must be escaped as %% in %-style templates — easy to forget, and one reason this style is error-prone for anything beyond simple logging.
name, score = "Ada", 97.5
print("%s scored %.1f%%" % (name, score)) # 'Ada scored 97.5%'
print("%(name)s: %(score)d" % {"name": name, "score": 90})Escaping Braces and Special Characters
In f-strings and str.format() templates, a literal brace is written by doubling it: {{ produces { and }} produces }. This is necessary whenever the output itself should contain JSON-like or set/dict-like syntax.
key, value = "x", 1
print(f"{{'{key}': {value}}}") # {'x': 1}
print("{{}} means an empty dict-looking literal".format())Debugging Shortcut: The = Specifier
Since Python 3.8, adding = right after an expression in an f-string prints both the expression's source text and its value — a fast way to inspect variables without writing print(f"x={x}") by hand.
width, height = 12, 7
print(f"{width=}, {height=}")
# width=12, height=7
area = width * height
print(f"{area = }") # spaces around = are preserved: area = 84Nested and Dynamic Format Specs
A format spec's width or precision can itself be a replacement field, letting you compute alignment dynamically — useful for building tables where column width depends on the data.
value = 3.14159
for prec in (1, 3, 5):
print(f"{value:.{prec}f}")
col_width = max(len(w) for w in ["apple", "fig", "banana"]) + 2
for word in ["apple", "fig", "banana"]:
print(f"{word:<{col_width}}|")Best Practices
Prefer f-strings for almost all new code — they are the fastest of the three at runtime (CPython implementation detail: the expression is compiled directly into bytecode rather than parsed from a string at call time) and the clearest to read since the value sits right where it's used. Reach for str.format() only when the template must be data (loaded from a file, translated, or built dynamically). Avoid % formatting in new code except when interfacing with APIs that expect it (some logging calls intentionally use %s style so the string is only formatted if the log level is enabled).
Keep expressions inside {} short and readable; if an expression needs a comment or a multi-step calculation, compute it in a variable on the line above rather than crowding the f-string.
Key Takeaways
f-strings (f"..."), str.format(), and % formatting share one format specification mini-language for width, alignment, precision, and numeric type.
f-strings evaluate real Python expressions inline and are the modern default; use str.format() when the template itself is dynamic data.
:.Nf fixes decimal places, :, adds thousands separators, </>/^ control alignment, and {{/}} escape literal braces.
{expr=} (3.8+) is a quick debugging shortcut that prints both the expression text and its value.
% formatting is legacy for general use but still appears in logging APIs and older codebases.
print("Total: " + total) print(f"Total: {total}") Python does not implicitly convert numbers to strings for + concatenation — mixing str and int with + raises TypeError. f-strings convert automatically.
"{} and {}".format("only one arg") "{} and {}".format("first", "second")
Every unnamed {} placeholder needs a corresponding positional argument; a mismatch raises IndexError at the .format() call.
f"Discount: {discount}%" # discount = 0.2, prints 'Discount: 0.2%' f"Discount: {discount:.0%}" # prints 'Discount: 20%'
Forgetting the % format type means the raw fraction is printed with a literal '%' appended, instead of being multiplied by 100 and formatted as a percentage.
template = f"Hello, {name}" # built once, name changes later template = "Hello, {name}"... template.format(name=current_name)
An f-string's expressions are evaluated immediately when the literal executes, not lazily. To defer substitution to a later point (or reuse a template with different values), use str.format() or a template object instead.
"Say %s" % name # name contains a literal '%' "Say %s" % (name,) # and escape any literal % as %% in the template
With %-style formatting, a single non-tuple argument is accepted, but if it happens to be a string containing '%' formatting characters or the template has more than one placeholder, results get confusing fast — wrap the argument in a tuple and escape literal percent signs as %%.
What is the difference between str.format() and an f-string in terms of when the substituted expression is evaluated?
An f-string's expressions are evaluated immediately, at the point the literal executes in the source code. str.format() separates the template (a plain string, which can be stored, loaded, or passed around) from the values, which are supplied later as arguments — useful when the template itself needs to be dynamic or reused.
What does f"{3.14159:.2f}" produce, and why?
'3.14' — the .2f spec means fixed-point notation with 2 digits after the decimal point, so 3.14159 is rounded to 3.14.
How do you include a literal { character in an f-string's output?
Double it: {{ in the template produces a single { in the output (and }} produces }).
Why is % formatting generally discouraged for new code, and where does it still legitimately appear?
It is more error-prone (positional order mistakes, awkward multi-value syntax, needing %% to escape literal percent signs) and has less readable error messages than f-strings or .format(). It still appears in some logging library calls, where passing %s-style arguments lets the library skip formatting entirely when a log level is disabled.
What does f"{value=}" do, and since which Python version?
Introduced in Python 3.8, it prints both the source text of the expression ('value') and its value, e.g. value=42 — a quick way to inspect a variable without writing the label manually.
Given word = 'hi', what does f"{word:>6}" produce?
' hi' — right-aligned within a 6-character field, padded with 4 leading spaces (strings default to left-align, but > explicitly requests right-align).
Why does f"{2.5:.0f}" produce '2' rather than '3'?
Python's f format type rounds ties to the nearest even digit (round-half-to-even, the same rule round() uses), and 2.5 in this rounding scheme rounds down to the even neighbor 2, not up to 3.
Write a function format_price(amount) that takes a float and returns it formatted as US currency with a thousands separator and exactly two decimal places, e.g. 1234.5 -> '$1,234.50'.
def format_price(amount: float) -> str: return f"${amount:,.2f}" print(format_price(1234.5)) # $1,234.50 print(format_price(7)) # $7.00
Use the combined spec ,.2f: , inserts thousands separators and .2f fixes two decimal places, both applied inside one f-string field.
What does this code print?
'+5 and -5' — the + sign option forces an explicit sign on numbers, showing '+' for positive values and '-' (as always) for negative ones.
Write a function table_row(label, value, width=15) that returns a string with label left-aligned and value right-aligned, each padded to width characters, separated by a single space.
def table_row(label: str, value, width: int = 15) -> str: return f"{label:<{width}} {value:>{width}}" print(table_row("Revenue", 125000)) print(table_row("Cost", 98250.5))
Nested replacement fields let width be supplied as a variable inside the format spec itself: {label:<{width}} reads the alignment character < and then substitutes width for the numeric width.
What does this print, and what would happen if the tuple ("Ada", 95) were replaced with just "Ada"?
It prints 'Ada scored 95%'. If replaced with just "Ada" (no tuple), Python would raise TypeError because the template has two conversion specifiers (%s and %d) but only one value was supplied — %-formatting with multiple placeholders requires a tuple of matching length.
value = 5
print(f"{value:+d} and {-value:+d}")print("%s scored %d%%" % ("Ada", 95))