Python 3.12+ · Updated November 2026 · Every program explained line by line

Python String Formatting
f-strings, format(), and the % operator

A complete, runnable reference for Python string formatting. Every code block includes a numbered line-by-line breakdown, so you understand exactly what the mini-language produces — plus alignment, number formatting, date formatting, and the full format-spec cheat table.

name = "Ana"
score = 95.678
items = 1234567
ratio = 0.856

print(f"Hello, {name}!")
print(f"Score: {score:.1f}")
print(f"Total: {items:,}")
print(f"Ratio: {ratio:.1%}")
print(f"Padded: |{name:>8}|")
print(f"Debug: {name=} {score=}")
Written & reviewed by Glenn Junsay Pansensoy Last reviewed: November 2026 Python 3.12+ ~25 min read

🎯 Why String Formatting Matters

Almost every program produces output — logs, reports, user-facing messages, API responses. Formatting is how you turn raw values into readable text.

Python has three main ways to interpolate values into strings. They've evolved over time: the % operator came first (1991), then str.format() in Python 2.6, and finally f-strings in Python 3.6. Each has a place, but for new code, f-strings are almost always the right choice — they're the fastest, the most readable, and support the full format spec mini-language.

The three approaches, side by side

f-strings: f"Hello, {name}!" — inline expressions, best for new code.
format(): "Hello, {}!".format(name) — useful for reusable templates.
% operator: "Hello, %s!" % name — legacy; still used by logging.

💡 All three approaches share the same underlying format-spec mini-language. Once you learn that {score:.2f} means "fixed-point, two decimal places", you can use the same syntax with f-strings, format(), and even format(score, ".2f") directly.

⚡ f-strings — The Modern Way

Introduced in Python 3.6, f-strings are evaluated at runtime and support arbitrary expressions

🔤 Basic f-string Syntax

Prefix a string with f or F. Anything inside curly braces is evaluated as a Python expression and its result is inserted. The result is converted with str() by default.

name = "Ana"
age = 25

print(f"Hello, {name}!")
print(f"{name} is {age} years old.")
print(f"In five years, {name} will be {age + 5}.")
print(f"Name uppercase: {name.upper()}")
print(f"List: {[x * 2 for x in range(5)]}")
Line-by-line explanation
  1. 1–2Two ordinary variables: a string and an integer.
  2. 4The f prefix before the quote marks this as an f-string. {name} is replaced with the value of name — result: "Hello, Ana!".
  3. 5Multiple placeholders work fine. Both are replaced in one pass. Result: "Ana is 25 years old."
  4. 6Expressions, not just variable names, are allowed inside braces. {age + 5} computes 30 before insertion. Result: "In five years, Ana will be 30."
  5. 7Method calls work too: {name.upper()} → "ANA". Result: "Name uppercase: ANA".
  6. 8Even comprehensions: {[x * 2 for x in range(5)]} evaluates to [0, 2, 4, 6, 8] and inserts its string form. Result: "List: [0, 2, 4, 6, 8]".
💡 f-strings are evaluated once, at the moment the line executes. If name changes after the line runs, the printed string doesn't — it's a snapshot. This is different from str.format(), which you can call later with new values.

🎛️ Format Spec After the Colon

After the expression, a colon begins a format spec — the mini-language that controls width, alignment, precision, and type. The syntax is: {expression:fill align sign width .precision type}.

price = 1234.5678
ratio = 0.856
count = 42

print(f"{price:.2f}")
print(f"{price:>12.2f}")
print(f"{price:,.2f}")
print(f"{ratio:.1%}")
print(f"{count:05d}")
print(f"{count:b}")
print(f"{count:x}")
print(f"{count:,}")
Line-by-line explanation
  1. 1–3Three test values: a float for money, a ratio between 0 and 1, and a small integer.
  2. 5{price:.2f} — fixed-point with 2 decimal places. Result: "1234.57". The f suffix means "float" format.
  3. 6{price:>12.2f} — right-align in a 12-character field, with 2 decimal places. Result: " 1234.57" (6 spaces + 7 characters).
  4. 7{price:,.2f} — comma thousands separator, 2 decimals. Result: "1,234.57".
  5. 8{ratio:.1%} — percentage format with 1 decimal place. Multiplies by 100 and appends %. Result: "85.6%".
  6. 9{count:05d} — integer, zero-padded to width 5. Result: "00042".
  7. 10{count:b} — binary representation. Result: "101010".
  8. 11{count:x} — hexadecimal (lowercase). Result: "2a". Use X for uppercase hex.
  9. 12{count:,} — integer with comma separator. Result: "42". (Nothing changes for small numbers, but {1234567:,} gives "1,234,567".)
💡 The d in :05d means "decimal integer". You can omit it for most cases, but including it makes the intent explicit and prevents errors if the value is a float — {3.0:05d} raises an error, which is often what you want.

🔍 The = Debug Feature

Python 3.8 added a debugging shorthand: put = directly after the expression name in the f-string, and both the source text and its value are printed. Hugely useful during development.

name = "Ana"
score = 95.678
items = [1, 2, 3]

print(f"{name=}")
print(f"{score=}")
print(f"{items=}")
print(f"{score=.2f}")
print(f"{len(items)=}")
Line-by-line explanation
  1. 1–3Three variables to inspect.
  2. 5{name=} — outputs name='Ana'. The = after the variable name tells Python to print the expression source and its repr on both sides of =.
  3. 6{score=} — outputs score=95.678. Numeric types print without quotes.
  4. 7{items=} — outputs items=[1, 2, 3]. Containers use their repr().
  5. 8{score=.2f} — outputs score=95.68. The format spec after = controls the printed value while keeping the debug prefix.
  6. 9{len(items)=} — outputs len(items)=3. Expressions, not just variables, work with =.
💡 The = debug syntax is a huge time-saver during development. Instead of writing print(f"name = {name!r}"), you just write print(f"{name=}") and get identical output.

⚙️ str.format() — Reusable Templates

Useful when the format string is data, or when you want to reuse a template with different values

🔢 Positional and Named Placeholders

format() supports both positional ({} or {0}) and named ({name}) placeholders. The same value can be reused.

template = "Hello, {}! You have {} messages."
print(template.format("Ana", 5))

template = "Hello, {0}! You have {1} messages. Bye, {0}."
print(template.format("Ana", 5))

template = "Hello, {name}! You have {count} messages."
print(template.format(name="Ana", count=5))

price = 1234.5678
print("Total: ${:,.2f}".format(price))
Line-by-line explanation
  1. 1A template string with two empty placeholders {}. This string can be stored, reused, or loaded from a config file.
  2. 2.format("Ana", 5) fills the placeholders left-to-right with positional arguments. Result: "Hello, Ana! You have 5 messages."
  3. 4Explicit positional indices. {0} and {1} reference the first and second arguments by position.
  4. 5The same index can appear multiple times — {0} fills both "Ana" slots. Result: "Hello, Ana! You have 5 messages. Bye, Ana."
  5. 7Named placeholders. More readable when there are several arguments — no risk of mixing up order.
  6. 8.format(name="Ana", count=5) fills the named placeholders by keyword.
  7. 11format spec works identically: {:,.2f} on a float produces a thousands-separated number with 2 decimals.
💡 The key advantage of format() over f-strings: the template can be created once and reused. This matters when the template is loaded from a database, a config file, or a user-provided string. F-strings always require the literal template to be in the source code.

📦 format() with Dictionaries

Placeholders can access dictionary keys and list items using a compact syntax. Useful for templating where the data is already a dict.

user = {"name": "Ana", "age": 25}
scores = [95, 88, 92]

print("{name} is {age}.".format(**user))
print("First score: {s[0]}, last: {s[-1]}".format(s=scores))
print("Name: {u[name]}, Age: {u[age]}".format(u=user))
Line-by-line explanation
  1. 1A dictionary with keys name and age.
  2. 2A list of numbers.
  3. 4.format(**user) unpacks the dict into keyword arguments. The placeholders {name} and {age} match dict keys. Result: "Ana is 25."
  4. 5Index access inside a placeholder: {s[0]} and {s[-1]}. Result: "First score: 95, last: 92".
  5. 6Key access inside a placeholder: {u[name]} looks up user["name"]. Note: no quotes inside the brackets. Result: "Name: Ana, Age: 25".
💡 The **user unpacking is not required — you can also write .format(name=user["name"], age=user["age"]). But **user is more concise when the dict keys match the placeholder names exactly.

% Operator — Legacy, But Still Useful

Older than Python 3, but you'll still see it in logging, older codebases, and string-formatting-heavy libraries

📜 The Old-Style % Operator

The % operator works like printf() in C. It uses type-specific placeholders like %s, %d, and %f, plus optional width and precision modifiers.

name = "Ana"
age = 25
price = 1234.5678
ratio = 0.856

print("Hello, %s!" % name)
print("%s is %d years old." % (name, age))
print("Price: $%.2f" % price)
print("Ratio: %.1f%%" % (ratio * 100))
print("Padded: %10s" % name)
print("Comma: %(price).2f" % {"price": price})
Line-by-line explanation
  1. 1–4Four values to format: a string, an integer, a float, and a ratio.
  2. 6%s substitutes as a string. Result: "Hello, Ana!".
  3. 7Multiple substitutions need a tuple: % (name, age). %d is decimal integer. Result: "Ana is 25 years old."
  4. 8%.2f — float with 2 decimals. Result: "Price: $1234.57".
  5. 9%.1f%% — the first % is format, the second literal % renders as %% in the format string. Result: "Ratio: 85.6%".
  6. 10%10s — right-align in a 10-character field. Result: "Padded: Ana".
  7. 11%(price).2f — named placeholder substituted from a dict. Result: "Comma: 1234.57". Note: % has no thousands-separator syntax — for that you need format() or f-strings.
💡 The one place % is still idiomatic in Python 3.12+ is logging: logger.debug("Processing %s items", count). The logging module defers the string interpolation until the log record is actually written, which is faster than formatting eagerly. Don't rewrite working logging code to use f-strings.

↔️ Alignment and Padding

Control how values fill a field — essential for tabular output and fixed-width reports

↔️ Left, Right, Centre, and Fill Characters

An alignment character goes before the width. Optional fill character before the alignment. The syntax is: {value:[fill][align][width]}.

name = "Ana"
id_num = 42

print(f"|{name:<10}|")
print(f"|{name:>10}|")
print(f"|{name:^10}|")
print(f"|{name:*<10}|")
print(f"|{name:*>10}|")
print(f"|{name:*^10}|")
print(f"|{id_num:05d}|")
print(f"|{id_num:<05d}|")

header = f"{'Name':<10}{'Score':>8}{'Rank':^6}"
print(header)
print("-" * len(header))
print(f"{'Ana':<10}{95:>8}{1:^6}")
print(f"{'Carlos':<10}{88:>8}{2:^6}")
Line-by-line explanation
  1. 1–2A short string and a small number.
  2. 4{name:<10} — left-align in 10 characters. Result: "|Ana |" (7 trailing spaces).
  3. 5{name:>10} — right-align. Result: "| Ana|".
  4. 6{name:^10} — centre. Result: "| Ana |".
  5. 7{name:*<10} — left-align but pad with * instead of spaces. Result: "|Ana*******|".
  6. 8{name:*>10} — right-align with *. Result: "|*******Ana|".
  7. 9{name:*^10} — centre with *. Result: "|***Ana****|".
  8. 10{id_num:05d} — zero-padding to width 5 for integers. Result: "|00042|". Note: 0 before the width means "fill with zeros".
  9. 11{id_num:<05d} — explicit left-align conflicts with zero-fill, and Python raises ValueError. For zero-padding, don't specify align.
  10. 13–17A complete table example: three columns aligned differently (left, right, centre), header on top, separator under the header.
💡 For alignment with numbers, the sign becomes part of the width. f"{-42:5d}" gives " -42" (5 chars including the minus sign). If you want positive numbers to have a leading space too, use f"{42: 5d}" — the space flag pads positive numbers to align with negatives.

🔢 Number Formatting

Thousands separators, precision, percentages, and alternate bases

💰 Money and Percentages

The most common real-world formatting needs: currency display with two decimals and thousands separators, and percentages.

prices = [1234.5, 0.99, 9876543.21]
ratio = 0.0725

for p in prices:
    print(f"${p:,.2f}")

print(f"Tax rate: {ratio:.2%}")
print(f"Tax rate: {ratio:.4%}")
print(f"Saved: {prices[2] - prices[0]:+,.2f}")
Line-by-line explanation
  1. 1–2Three prices and a tax ratio.
  2. 4–5Loop over prices and format each as currency. {p:,.2f} produces $1,234.50, $0.99, $9,876,543.21.
  3. 7{ratio:.2%} — 2 decimal places, percentage. Result: "7.25%".
  4. 8{ratio:.4%} — 4 decimal places. Result: "7.2500%".
  5. 9The + sign flag forces an explicit sign on positive numbers. Result: "+9,875,308.71". Useful for deltas and gains/losses.

🔄 Binary, Octal, Hexadecimal

Use b, o, or x in the format spec to convert integers to other bases. Add # for the base prefix.

n = 255

print(f"decimal: {n:d}")
print(f"binary: {n:b}")
print(f"octal: {n:o}")
print(f"hex: {n:x}")
print(f"HEX: {n:X}")
print(f"prefixed: {n:#x}")
print(f"padded binary: {n:08b}")
Line-by-line explanation
  1. 1255 is the maximum unsigned byte value — a good test case.
  2. 3{n:d} — decimal. Result: "decimal: 255".
  3. 4{n:b} — binary. Result: "binary: 11111111".
  4. 5{n:o} — octal. Result: "octal: 377".
  5. 6{n:x} — hexadecimal lowercase. Result: "hex: ff".
  6. 7{n:X} — hexadecimal uppercase. Result: "HEX: FF".
  7. 8{n:#x} — with 0x prefix. Result: "prefixed: 0xff".
  8. 9{n:08b} — binary, zero-padded to 8 digits. Result: "padded binary: 11111111". Use this for byte-aligned output.
💡 Precision in the format spec only applies to float types. Writing f"{42:.2f}" works (Python converts to 42.00), but f"{42:.2d}" raises ValueError — integers don't have precision.

📅 Date and Time Formatting

datetime objects use strftime() codes, but f-strings can format them directly

📅 Formatting datetime Objects

A datetime object supports the strftime() method for format-string conversion. The format() mini-language also accepts datetime-specific format specs — an alternative to strftime codes.

from datetime import datetime

now = datetime(2026, 11, 3, 14, 30, 45)

print(now.strftime("%Y-%m-%d"))
print(now.strftime("%d %B %Y"))
print(now.strftime("%H:%M:%S"))
print(now.strftime("%Y-%m-%d %H:%M:%S"))

print(f"{now:%Y-%m-%d}")
print(f"{now:%d %B %Y}")
print(f"{now:%A, %d %B %Y}")
print(f"Today is {now:%A}")
Line-by-line explanation
  1. 1Imports datetime from the standard library.
  2. 3Creates a specific datetime: 3 November 2026, 14:30:45.
  3. 5strftime("%Y-%m-%d") — ISO date. Result: "2026-11-03".
  4. 6strftime("%d %B %Y") — day, full month name, year. Result: "03 November 2026".
  5. 7strftime("%H:%M:%S") — 24-hour time. Result: "14:30:45". Use %I for 12-hour.
  6. 8Combining date and time. Result: "2026-11-03 14:30:45".
  7. 10Inside an f-string, the format spec after the colon is treated as a strftime format. Result: "2026-11-03".
  8. 11Result: "03 November 2026" — same as line 6.
  9. 12%A is the full weekday name. Result: "Tuesday, 03 November 2026". Because 3 Nov 2026 falls on a Tuesday.
  10. 13Combined with prose. Result: "Today is Tuesday".
💡 The format() mini-language for datetimes is mostly identical to strftime() codes, but not every strftime code is supported — some platform-specific codes are omitted. For anything exotic, use strftime() explicitly.

🔍 !r vs !s — Choosing the Conversion

By default, f-strings use str(). Add !r for repr(), or !a for ascii().

🔍 str(), repr(), and ascii()

str() gives the human-readable form, repr() gives the developer-readable form (with quotes and escapes), and ascii() is like repr() but escapes non-ASCII characters.

name = "Ana"
text = "café\nnext"
items = [1, 2, "three"]

print(f"Default: {name}")
print(f"!r: {name!r}")
print(f"!s: {name!s}")
print(f"Default: {text}")
print(f"!r: {text!r}")
print(f"!a: {text!a}")
print(f"List default: {items}")
print(f"List !s: {items!s}")
Line-by-line explanation
  1. 1–3Three test values: a plain string, a string with non-ASCII and a newline, and a mixed list.
  2. 5Default conversion is str(). Result: "Default: Ana".
  3. 6!r applies repr() first. Result: "!r: 'Ana'" — with quotes.
  4. 7!s is explicit about the default. Result: "!s: Ana".
  5. 8Default str for a string with a newline renders the newline. Result: two lines — café then next.
  6. 9!r shows escapes and quotes literally. Result: "!r: 'café\\nnext'".
  7. 10!a is repr with non-ASCII escaped. Result: "!a: 'caf\\xe9\\nnext'".
  8. 11Default str on a list is actually the same as repr — lists don't have a separate human form. Result: "List default: [1, 2, 'three']".
  9. 12!s gives the same as default here. Result: "List !s: [1, 2, 'three']".
💡 Rule of thumb: use !r when debugging (you want to see quotes and escapes), and default (str) when displaying to users. In logs, !r is almost always more useful because it reveals the true type and content of values.

⚖️ Which Method Should You Use?

A practical comparison of f-strings, format(), and %

Feature f-string format() % operator
Introduced inPython 3.6Python 2.6Python 1.0
Syntaxf"Hello {name}""Hello {}".format(name)"Hello %s" % name
ReadabilityBestGoodFair
PerformanceFastestFastSlowest
Format spec mini-languageYes — fullYes — fullLimited
Named placeholdersYesYesYes (with dict)
Positional placeholdersNoYesYes
Reusable templateNoYesYes
Arbitrary expressionsYesLimited (need vars)No
F-string = debugYesNoNo
Thousands separatorsYesYesNo

Recommendation for 2026

Use f-strings for almost everything. Reach for str.format() when the template is dynamic (loaded from a config, database, or user input). Reserve the % operator for logging calls and when maintaining legacy code.

📋 Format Spec Mini-Language — Full Reference

The general structure and every common spec at a glance

[[fill]align][sign][#][0][width][,][.precision][type]

Spec Example Meaning
df"{42:d}" → 42Decimal integer
bf"{42:b}" → 101010Binary
of"{42:o}" → 52Octal
xf"{42:x}" → 2aHexadecimal (lowercase)
Xf"{42:X}" → 2AHexadecimal (uppercase)
ff"{3.14:f}" → 3.140000Fixed-point (default 6 decimals)
ef"{3.14:e}" → 3.140000e+00Scientific notation
gf"{3.14:g}" → 3.14General (auto-selects f or e)
%f"{0.85:%}" → 85.000000%Percentage
sf"{'hi':s}" → hiString (default for strings)
cf"{65:c}" → ACharacter (from integer code point)
nf"{1234:n}" → 1,234Locale-aware number
0f"{42:05d}" → 00042Zero-pad to width 5
,f"{1234567:,}" → 1,234,567Thousands separator
_f"{1234567:_}" → 1_234_567Underscore separator
+f"{42:+d}" → +42Force sign for positive numbers
(space)f"{42: d}" → " 42"Space for positive, minus for negative
<f"{'x':<5}" → "x "Left-align
>f"{'x':>5}" → " x"Right-align
^f"{'x':^5}" → " x "Centre
=f"{-42:=5d}" → "- 42"Pad after sign
*f"{'x':*<5}" → "x****"Fill character (any)
.Nf"{3.14159:.2f}" → 3.14N digits after decimal point
.f"{'hello':.3}" → helPrecision for strings (max length)

🏋️ Practice — Check Your Understanding

Three exercises with solutions. Try each before expanding the answer.

Exercise 1 — Predict the Output

What does each line print? Write your answers before expanding.

value = 1234.5678
ratio = 0.0745
n = 42

print(f"{value:,.1f}")
print(f"{ratio:.2%}")
print(f"{n:06d}")
print(f"{n:b}")
print(f"{'python':>10}")
print(f"{'python':^10}")
Show solution

Line 1: 1,234.6 — comma separator, 1 decimal.
Line 2: 7.45% — 2 decimal percentage.
Line 3: 000042 — zero-padded to 6 characters.
Line 4: 101010 — binary of 42.
Line 5: python — right-aligned in 10 chars (4 leading spaces).
Line 6: python — centred in 10 chars (2 spaces each side).

Exercise 2 — Fix the Bug

This program raises ValueError. Why? Fix it.

n = 42
print(f"{n:05.2f}")
Show solution

Integer values don't accept precision in the format spec — .2f means "2 decimal places as a float", but n is an int. Python raises ValueError: Precision not allowed in integer format specifier.

print(f"{n:05d}")
print(f"{float(n):05.2f}")

Option 1 drops the precision: :05d → 00042. Option 2 casts to float first: 42.00 in a 5-width field.

Exercise 3 — Build a Receipt Line

Given these variables, produce a receipt line with the name left-aligned in 20 chars, the quantity right-aligned in 5 chars, and the price right-aligned in 10 chars with 2 decimals and a thousands separator.

name = "Notebook"
quantity = 3
price = 1234.5
Show solution
print(f"{name:<20}{quantity:>5}{price:>10,.2f}")

Output: "Notebook 3 1,234.50". The three format specs stack cleanly because each has a fixed width — the columns line up in a table.

❓ Frequently Asked Questions

Common questions about Python string formatting, answered directly

Use f-strings for new code in Python 3.6+. They are the fastest, most readable, and support the full format spec mini-language. Use str.format() when you need to reuse a template or when the format string is data (not a literal). Use the % operator only when maintaining old code or when interfacing with libraries (like logging) that expect it. All three share the same format spec mini-language, so what you learn transfers.

Use a comma in the format spec: f"{1234567:,}" produces "1,234,567". For non-English locales, use the n format type — f"{1234567:n}" respects the current locale set via locale.setlocale(). For underscores instead of commas, use _: f"{1234567:_}" gives "1_234_567".

Use the .2f format spec: f"{3.14159:.2f}" produces "3.14". The number after the dot is the precision — the number of digits to show after the decimal point. The f format type means fixed-point notation. Note that this formats the number; it doesn't change the underlying float value. If you need to actually round the stored value, use round(value, 2) separately.

Use width and alignment in the format spec. f"{name:>10}" right-aligns in 10 characters, f"{name:<10}" left-aligns, f"{name:^10}" centres. An optional fill character goes before the alignment: f"{name:*>10}" pads with asterisks instead of spaces. For numbers, f"{42:05d}" pads with zeros — the 0 before the width means zero-fill (only valid for numeric types).

!r applies repr() before formatting, showing the developer-readable form (strings get quotes, escapes show literally). !s applies str(), the human-readable form. Plain {var} in an f-string uses str() by default — use !r for debugging output. There's also !a which is like !r but escapes non-ASCII characters.

Educational Use Disclaimer

This page is part of Poetic Bytes, a free educational resource for learning Python programming. The code examples are provided for instruction and demonstration. They are not production-audited and should not be used as-is in security-sensitive, financial, or safety-critical systems without independent review. See our full disclaimer and terms of use.

🗣️ Learner Voices

No comments yet — your voice starts the conversation.