F-Strings: Modern String Formatting
1 · The lesson
readF-strings — formatted string literals, introduced in Python 3.6 — are the way to build strings in modern Python. They're faster than the alternatives, they read left-to-right like English, and they put the expression right next to where it appears in the output. Once you've used them for a week the older formatting styles look painful.
1. The Bad Old Days
Before f-strings, Python had two formatting systems. You'll still see both in older codebases.
name = "Margaret" age = 30 # 1. %-formatting (Python 2 era, modelled on C's printf) print("Hi, %s. You are %d." % (name, age)) # 2. str.format() (Python 2.6+, positional or named placeholders) print("Hi, {}. You are {}.".format(name, age)) print("Hi, {n}. You are {a}.".format(n=name, a=age)) # 3. f-strings (Python 3.6+) — the modern way print(f"Hi, {name}. You are {age}.")
All three produce the same output. The f-string is the only one where you don't have to keep your eyes bouncing between the placeholder and the argument list.
2. Any Expression Goes Inside {}
The braces aren't just variable slots — they evaluate any Python expression.
x, y = 3, 4 print(f"{x + y}") # '7' — arithmetic print(f"{x * y + 1}") # '13' name = "surya" print(f"{name.upper()}") # 'SURYA' — method calls items = ["apples", "bread", "milk"] print(f"First: {items[0]}") # 'First: apples' — indexing print(f"Count: {len(items)}") # 'Count: 3' — function calls import math print(f"π ≈ {math.pi:.4f}") # 'π ≈ 3.1416' — attributes + format
Keep it readable. If the expression is more than a quick lookup or one operation, compute it on a separate line and interpolate the result.
3. Format Specifiers — The Useful Half
After a colon inside the braces comes the format spec. This is where f-strings really earn their keep.
Width and Alignment
name = "Margaret" print(f"|{name:>10}|") # '| Margaret|' — right-aligned, width 10 print(f"|{name:<10}|") # '|Margaret |' — left-aligned print(f"|{name:^10}|") # '| Margaret |' — centered print(f"|{name:*^10}|") # '|**Margaret***|' — fill with '*'
You'll use this for tables, receipts, anything that lines up in columns.
Numbers — Decimals, Thousands, Percent, Scientific
pi = 3.14159265 count = 1_234_567 ratio = 0.0847 big = 12_345_000_000 print(f"{pi:.2f}") # '3.14' — 2 decimal places print(f"{pi:.5f}") # '3.14159' — 5 decimal places print(f"{count:,}") # '1,234,567' — thousands separators print(f"{count:,.2f}") # '1,234,567.00' — combined print(f"{ratio:.1%}") # '8.5%' — percent (multiplies by 100) print(f"{big:.2e}") # '1.23e+10' — scientific notation
Padding Integers with Zeros
For dates, IDs, ports — anything that should look like 007, not 7.
day = 5 month = 9 year = 2026 print(f"{year}-{month:02d}-{day:02d}") # '2026-09-05' invoice_id = 42 print(f"INV-{invoice_id:06d}") # 'INV-000042'
The 02d means "integer, width 2, padded with zeros on the left."
4. The Debug = Syntax (Python 3.8+)
This one is a print-debugging superpower. Put = after the expression and the f-string prints both the expression text and its value.
x = 42 y = [1, 2, 3] print(f"{x=}") # 'x=42' print(f"{y=}") # 'y=[1, 2, 3]' print(f"{len(y)=}") # 'len(y)=3' print(f"{x * 2=}") # 'x * 2=84'
When you're debugging and want to see "what is this variable right now?" — f"{variable=}" is the shortest path. You don't have to type the name twice.
# Before: print("user_id:", user_id, "balance:", balance) # After: print(f"{user_id=}, {balance=}")
setup added so this can run · defines user_id, balance
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) user_id = _AutoMock('user_id') balance = _AutoMock('balance')
5. Multi-Line F-Strings
Triple quotes work with the f prefix too. Useful for templates, prompts, emails.
name = "Margaret" items = 3 total = 47.25 receipt = f""" Customer: {name} Items: {items} Total: ${total:.2f} Thanks for shopping with us! """ print(receipt)
You can also concatenate adjacent string literals — Python joins them at parse time — which lets you split long f-strings without an explicit +:
msg = (
f"Order confirmation for {name}: "
f"{items} items totalling ${total:.2f}."
) setup added so this can run · defines name, items, total
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) name = _AutoMock('name') items = _AutoMock('items') total = _AutoMock('total')
6. When NOT to Use F-Strings
F-strings interpolate at the moment the f prefix is parsed. If your template lives in a config file, a database, or a variable, the f does nothing useful — you need str.format() with a dictionary:
template = "Hi, {name}. You owe ${amount}." # plain string — no 'f' data = {"name": "Margaret", "amount": 42} print(template.format(**data)) # 'Hi, Margaret. You owe $42.'
This is the right tool when:
- the template is data (loaded from a file, edited by a non-programmer),
- the same template is reused with different variables,
- you're building something like an email or notification system.
The Mad Libs project walks through exactly this pattern.
7. Three Gotchas
1. F-strings only work at the literal site. This catches everyone once.
template = "Hello, {name}" # plain string — the f was forgotten name = "Margaret" print(template) # 'Hello, {name}' — literal braces, no substitution
If you find yourself wanting to "store an f-string for later", you actually want str.format() or a function that builds the string when called.
2. No backslashes inside the {...} part.
items = ["a", "b", "c"] # This is a SyntaxError in Python < 3.12: print(f"{'\n'.join(items)}") # Pull the separator out first: sep = "\n" print(f"{sep.join(items)}")
Python 3.12 relaxed this rule (see PEP 701), but if your code needs to run on older versions, keep backslashes outside the braces.
3. Quote-nesting needs alternating styles. The outer quotes can't appear unescaped inside the expression.
user = {"name": "Margaret"}
# OK — outer double, inner single:
print(f"Name: {user['name']}")
# SyntaxError — outer and inner both double:
# print(f"Name: {user["name"]}")Pick the opposite quote style for the dict key, attribute access, or string literal inside {...}.
Common Mistakes
- Forgetting the
fprefix."Hi, {name}"is a literal string. Look for the prefix every time you read a template. - Format spec on the wrong side of the colon.
f"{x.2f}"is an attribute access (and an error).f"{x:.2f}"is the format spec. - Using f-strings for user-supplied templates. Never
eval-like — if a user can write the template, they can run arbitrary code. Usestr.format()with a fixed allowed key set instead. f"{x = }"vsf"{x=}". Whitespace inside the braces is preserved literally.f"{x = }"produces'x = 42';f"{x=}"produces'x=42'. Pick one and be consistent.
🎯 Your Turn — Build a Receipt Formatter
Write a function format_invoice(items) that takes a list of (name, qty, price) tuples and returns a right-aligned receipt as a single string.
Target output:
-------------------------------- Apple 3 @ $0.50 Coffee 1 @ $4.25 Notebook 2 @ $3.00 -------------------------------- TOTAL $11.75
Rules:
1. Item name is left-aligned in a 20-character column.
2. Quantity is right-aligned in a 4-character column.
3. Price is shown with 2 decimal places after $.
4. The total goes on the last line, right-aligned with the prices, with 2 decimals.
5. Top and bottom rules are 32 dashes.
Skeleton:
def format_invoice(items): lines = [] lines.append("-" * 32) total = 0 for name, qty, price in items: # TODO: build the line for this item # TODO: update the running total pass lines.append("-" * 32) # TODO: append the total line return "\n".join(lines) cart = [ ("Apple", 3, 0.50), ("Coffee", 1, 4.25), ("Notebook", 2, 3.00), ] print(format_invoice(cart))
Hint 1 — One line per item
For each tuple, the line is just one f-string with three format specs:line = f"{name:<20}{qty:>4} @ ${price:>6.2f}"
The line subtotal is qty * price — add it to the running total before appending.
Hint 2 — Aligning the total
The total line needs the word"TOTAL" on the left and the dollar amount right-aligned. Width 32 with the total at the end:
lines.append(f"{'TOTAL':<20}{'':>6}${total:>6.2f}")
Or just compute the right-side width as 32 - len("TOTAL") - 1 and use that explicitly.
Show full solution
def format_invoice(items): lines = ["-" * 32] total = 0.0 for name, qty, price in items: subtotal = qty * price total += subtotal lines.append(f"{name:<20}{qty:>3} @ ${price:>5.2f}") lines.append("-" * 32) lines.append(f"{'TOTAL':<20}{'':>6}${total:>5.2f}") return "\n".join(lines) cart = [ ("Apple", 3, 0.50), ("Coffee", 1, 4.25), ("Notebook", 2, 3.00), ] print(format_invoice(cart))
Output:
-------------------------------- Apple 3 @ $ 0.50 Coffee 1 @ $ 4.25 Notebook 2 @ $ 3.00 -------------------------------- TOTAL $11.75
Three format specs do all the alignment work: :<20 for the left-aligned name, :>3 for the right-aligned qty, :>5.2f for the right-aligned dollar amount with two decimals. No string padding, no manual spaces — the format mini-language handles it.
In a real billing system you'd add a currency symbol parameter, handle locales properly (commas vs dots), and probably switch to decimal.Decimal for money. But the formatting structure stays exactly the same.
What You Learned
- F-strings are
f"...{expression:format_spec}...". Thefprefix is mandatory. - Any expression — arithmetic, method calls, indexing — works inside
{}. - Format specs after
:handle width (>10,<10,^10), decimals (.2f), thousands (,), percent (.1%), scientific (.2e), zero-padding (02d). f"{x=}"printsx=value— the fastest print-debug syntax in Python.- Triple-quoted f-strings work for multi-line templates.
- For templates that are data (config, DB, user-editable), use
str.format(**dict)— see Mad Libs.
Next: Lists — the ordered, mutable workhorse collection that backs most Python data work.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.