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=}")
📚 What This Lesson Covers
Eleven sections, each with runnable code and a numbered explanation of every line
🎯 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.
{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)]}")
- 1–2Two ordinary variables: a string and an integer.
- 4The
fprefix before the quote marks this as an f-string.{name}is replaced with the value ofname— result:"Hello, Ana!". - 5Multiple placeholders work fine. Both are replaced in one pass. Result:
"Ana is 25 years old." - 6Expressions, not just variable names, are allowed inside braces.
{age + 5}computes30before insertion. Result:"In five years, Ana will be 30." - 7Method calls work too:
{name.upper()}→"ANA". Result:"Name uppercase: ANA". - 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]".
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:,}")
- 1–3Three test values: a float for money, a ratio between 0 and 1, and a small integer.
- 5
{price:.2f}— fixed-point with 2 decimal places. Result:"1234.57". Thefsuffix means "float" format. - 6
{price:>12.2f}— right-align in a 12-character field, with 2 decimal places. Result:" 1234.57"(6 spaces + 7 characters). - 7
{price:,.2f}— comma thousands separator, 2 decimals. Result:"1,234.57". - 8
{ratio:.1%}— percentage format with 1 decimal place. Multiplies by 100 and appends%. Result:"85.6%". - 9
{count:05d}— integer, zero-padded to width 5. Result:"00042". - 10
{count:b}— binary representation. Result:"101010". - 11
{count:x}— hexadecimal (lowercase). Result:"2a". UseXfor uppercase hex. - 12
{count:,}— integer with comma separator. Result:"42". (Nothing changes for small numbers, but{1234567:,}gives"1,234,567".)
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)=}")
- 1–3Three variables to inspect.
- 5
{name=}— outputsname='Ana'. The=after the variable name tells Python to print the expression source and its repr on both sides of=. - 6
{score=}— outputsscore=95.678. Numeric types print without quotes. - 7
{items=}— outputsitems=[1, 2, 3]. Containers use theirrepr(). - 8
{score=.2f}— outputsscore=95.68. The format spec after=controls the printed value while keeping the debug prefix. - 9
{len(items)=}— outputslen(items)=3. Expressions, not just variables, work with=.
= 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))
- 1A template string with two empty placeholders
{}. This string can be stored, reused, or loaded from a config file. - 2
.format("Ana", 5)fills the placeholders left-to-right with positional arguments. Result:"Hello, Ana! You have 5 messages." - 4Explicit positional indices.
{0}and{1}reference the first and second arguments by position. - 5The same index can appear multiple times —
{0}fills both"Ana"slots. Result:"Hello, Ana! You have 5 messages. Bye, Ana." - 7Named placeholders. More readable when there are several arguments — no risk of mixing up order.
- 8
.format(name="Ana", count=5)fills the named placeholders by keyword. - 11format spec works identically:
{:,.2f}on a float produces a thousands-separated number with 2 decimals.
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))- 1A dictionary with keys
nameandage. - 2A list of numbers.
- 4
.format(**user)unpacks the dict into keyword arguments. The placeholders{name}and{age}match dict keys. Result:"Ana is 25." - 5Index access inside a placeholder:
{s[0]}and{s[-1]}. Result:"First score: 95, last: 92". - 6Key access inside a placeholder:
{u[name]}looks upuser["name"]. Note: no quotes inside the brackets. Result:"Name: Ana, Age: 25".
**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})
- 1–4Four values to format: a string, an integer, a float, and a ratio.
- 6
%ssubstitutes as a string. Result:"Hello, Ana!". - 7Multiple substitutions need a tuple:
% (name, age).%dis decimal integer. Result:"Ana is 25 years old." - 8
%.2f— float with 2 decimals. Result:"Price: $1234.57". - 9
%.1f%%— the first%is format, the second literal%renders as%%in the format string. Result:"Ratio: 85.6%". - 10
%10s— right-align in a 10-character field. Result:"Padded: Ana". - 11
%(price).2f— named placeholder substituted from a dict. Result:"Comma: 1234.57". Note:%has no thousands-separator syntax — for that you needformat()or f-strings.
% 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}")
- 1–2A short string and a small number.
- 4
{name:<10}— left-align in 10 characters. Result:"|Ana |"(7 trailing spaces). - 5
{name:>10}— right-align. Result:"| Ana|". - 6
{name:^10}— centre. Result:"| Ana |". - 7
{name:*<10}— left-align but pad with*instead of spaces. Result:"|Ana*******|". - 8
{name:*>10}— right-align with*. Result:"|*******Ana|". - 9
{name:*^10}— centre with*. Result:"|***Ana****|". - 10
{id_num:05d}— zero-padding to width 5 for integers. Result:"|00042|". Note:0before the width means "fill with zeros". - 11
{id_num:<05d}— explicit left-align conflicts with zero-fill, and Python raisesValueError. For zero-padding, don't specify align. - 13–17A complete table example: three columns aligned differently (left, right, centre), header on top, separator under the header.
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}")
- 1–2Three prices and a tax ratio.
- 4–5Loop over prices and format each as currency.
{p:,.2f}produces$1,234.50,$0.99,$9,876,543.21. - 7
{ratio:.2%}— 2 decimal places, percentage. Result:"7.25%". - 8
{ratio:.4%}— 4 decimal places. Result:"7.2500%". - 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}")
- 1255 is the maximum unsigned byte value — a good test case.
- 3
{n:d}— decimal. Result:"decimal: 255". - 4
{n:b}— binary. Result:"binary: 11111111". - 5
{n:o}— octal. Result:"octal: 377". - 6
{n:x}— hexadecimal lowercase. Result:"hex: ff". - 7
{n:X}— hexadecimal uppercase. Result:"HEX: FF". - 8
{n:#x}— with0xprefix. Result:"prefixed: 0xff". - 9
{n:08b}— binary, zero-padded to 8 digits. Result:"padded binary: 11111111". Use this for byte-aligned output.
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}")
- 1Imports
datetimefrom the standard library. - 3Creates a specific datetime: 3 November 2026, 14:30:45.
- 5
strftime("%Y-%m-%d")— ISO date. Result:"2026-11-03". - 6
strftime("%d %B %Y")— day, full month name, year. Result:"03 November 2026". - 7
strftime("%H:%M:%S")— 24-hour time. Result:"14:30:45". Use%Ifor 12-hour. - 8Combining date and time. Result:
"2026-11-03 14:30:45". - 10Inside an f-string, the format spec after the colon is treated as a strftime format. Result:
"2026-11-03". - 11Result:
"03 November 2026"— same as line 6. - 12
%Ais the full weekday name. Result:"Tuesday, 03 November 2026". Because 3 Nov 2026 falls on a Tuesday. - 13Combined with prose. Result:
"Today is Tuesday".
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}")
- 1–3Three test values: a plain string, a string with non-ASCII and a newline, and a mixed list.
- 5Default conversion is
str(). Result:"Default: Ana". - 6
!rappliesrepr()first. Result:"!r: 'Ana'"— with quotes. - 7
!sis explicit about the default. Result:"!s: Ana". - 8Default str for a string with a newline renders the newline. Result: two lines —
caféthennext. - 9
!rshows escapes and quotes literally. Result:"!r: 'café\\nnext'". - 10
!ais repr with non-ASCII escaped. Result:"!a: 'caf\\xe9\\nnext'". - 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']". - 12
!sgives the same as default here. Result:"List !s: [1, 2, 'three']".
!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 in | Python 3.6 | Python 2.6 | Python 1.0 |
| Syntax | f"Hello {name}" | "Hello {}".format(name) | "Hello %s" % name |
| Readability | Best | Good | Fair |
| Performance | Fastest | Fast | Slowest |
| Format spec mini-language | Yes — full | Yes — full | Limited |
| Named placeholders | Yes | Yes | Yes (with dict) |
| Positional placeholders | No | Yes | Yes |
| Reusable template | No | Yes | Yes |
| Arbitrary expressions | Yes | Limited (need vars) | No |
F-string = debug | Yes | No | No |
| Thousands separators | Yes | Yes | No |
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 |
|---|---|---|
| d | f"{42:d}" → 42 | Decimal integer |
| b | f"{42:b}" → 101010 | Binary |
| o | f"{42:o}" → 52 | Octal |
| x | f"{42:x}" → 2a | Hexadecimal (lowercase) |
| X | f"{42:X}" → 2A | Hexadecimal (uppercase) |
| f | f"{3.14:f}" → 3.140000 | Fixed-point (default 6 decimals) |
| e | f"{3.14:e}" → 3.140000e+00 | Scientific notation |
| g | f"{3.14:g}" → 3.14 | General (auto-selects f or e) |
| % | f"{0.85:%}" → 85.000000% | Percentage |
| s | f"{'hi':s}" → hi | String (default for strings) |
| c | f"{65:c}" → A | Character (from integer code point) |
| n | f"{1234:n}" → 1,234 | Locale-aware number |
| 0 | f"{42:05d}" → 00042 | Zero-pad to width 5 |
| , | f"{1234567:,}" → 1,234,567 | Thousands separator |
| _ | f"{1234567:_}" → 1_234_567 | Underscore separator |
| + | f"{42:+d}" → +42 | Force 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) |
| .N | f"{3.14159:.2f}" → 3.14 | N digits after decimal point |
| . | f"{'hello':.3}" → hel | Precision 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.
📬 Python, weekly
Short lessons, real code, and ecosystem news — sent about once a week
🗣️ Learner Voices
No comments yet — your voice starts the conversation.