PythonMastery
beginner 13 min read · lesson 8 of 19 in Python Fundamentals

F-Strings: Modern String Formatting

1 · The lesson

read

F-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.

python
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.

python
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

python
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

python
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.

python
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.

python
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.

python
# 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.

python
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 +:

python
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:

python
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.

python
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.

python
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.

python
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 f prefix. "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. Use str.format() with a fixed allowed key set instead.
  • f"{x = }" vs f"{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:

python
--------------------------------
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:

python
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
python
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:

python
--------------------------------
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}...". The f prefix 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=}" prints x=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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.