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) were added in Python 3.6. They are the standard way to build strings in modern Python. They are faster than the older styles, they read from left to right, and each expression sits in the exact place where its value appears in the output.


1. Before F-Strings

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 the value is written inside the placeholder. With the other two, you have to match each placeholder to an argument at the end of the line.


2. Any Expression Goes Inside {}

The braces can hold more than a variable name. Python evaluates any expression you put inside them.

python
x, y = 3, 4
print(f"{x + y}")               # '7'                   (arithmetic)
print(f"{x * y + 1}")           # '13'

name = "surya"
print(f"{name.upper()}")        # 'SURYA'               (a method call)

items = ["apples", "bread", "milk"]
print(f"First: {items[0]}")     # 'First: apples'       (indexing)
print(f"Count: {len(items)}")   # 'Count: 3'            (a function call)

import math
print(f"π ≈ {math.pi:.4f}")     # 'π ≈ 3.1416'          (an attribute plus a format spec)

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

After a colon inside the braces comes the format spec. The format spec controls how the value is displayed: width, alignment, decimal places and more.

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*|'           (filled 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'        (both together)
print(f"{ratio:.1%}")           # '8.5%'                (percent, which multiplies by 100)
print(f"{big:.2e}")             # '1.23e+10'            (scientific notation)

Padding Integers with Zeros

Use this for dates, IDs and any other number 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 syntax is very useful for debugging with print(). Put = after the expression, and the f-string shows 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 are debugging and want to know the current value of a variable, f"{variable=}" is the shortest way to print it. 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. This is useful for templates, prompts and 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)

Python also joins string literals that are written next to each other, when it reads the code. This lets you split a long f-string over several lines without using +:

python
msg = (
    f"Order confirmation for {name}: "
    f"{items} items totalling ${total:.2f}."
)
+ setup added so this can run · re-runs 4 earlier blocks on this page first
# 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')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import math
except Exception:
    pass
_pm_earlier = [
    'name = "Margaret"\nage = 30\n\n# 1. %-formatting (Python 2 era, modelled on C\'s printf)\nprint("Hi, %s. You are %d." % (name, age))\n\n# 2. str.format() (Python 2.6+, positional or named placeholders)\nprint("Hi, {}. You are {}.".format(name, age))\nprint("Hi, {n}. You are {a}.".format(n=name, a=age))\n\n# 3. f-strings (Python 3.6+), the modern way\nprint(f"Hi, {name}. You are {age}.")',
    'x, y = 3, 4\nprint(f"{x + y}")               # \'7\'                   (arithmetic)\nprint(f"{x * y + 1}")           # \'13\'\n\nname = "surya"\nprint(f"{name.upper()}")        # \'SURYA\'               (a method call)\n\nitems = ["apples", "bread", "milk"]\nprint(f"First: {items[0]}")     # \'First: apples\'       (indexing)\nprint(f"Count: {len(items)}")   # \'Count: 3\'            (a function call)\n\nimport math\nprint(f"π ≈ {math.pi:.4f}")     # \'π ≈ 3.1416\'          (an attribute plus a format spec)',
    'name = "Margaret"\n\nprint(f"|{name:>10}|")          # \'|  Margaret|\'           (right-aligned, width 10)\nprint(f"|{name:<10}|")          # \'|Margaret  |\'           (left-aligned)\nprint(f"|{name:^10}|")          # \'| Margaret |\'           (centered)\nprint(f"|{name:*^10}|")         # \'|*Margaret*|\'           (filled with \'*\')',
    'name = "Margaret"\nitems = 3\ntotal = 47.25\n\nreceipt = f"""\nCustomer: {name}\nItems:    {items}\nTotal:    ${total:.2f}\nThanks for shopping with us!\n"""\nprint(receipt)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

6. When NOT to Use F-Strings

F-strings interpolate at the moment the f prefix is parsed. If your template is stored in a config file, a database or a variable, the f prefix can't help you. Use str.format() with a dictionary instead:

python
template = "Hi, {name}. You owe ${amount}."         # a plain string, with 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. An f-string is filled in only where it is written. Almost everyone makes this mistake once.

python
template = "Hello, {name}"      # a plain string, because the f was forgotten
name = "Margaret"
print(template)                 # 'Hello, {name}'   (the braces print as they are)

If you want to "store an f-string for later", what you need is str.format(), or a function that builds the string when you call it.

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: alternate the styles. Before Python 3.12, the outer quotes couldn't appear inside the expression.

python
user = {"name": "Margaret"}

# OK: double quotes outside, single quotes inside:
print(f"Name: {user['name']}")

# SyntaxError before Python 3.12, because both quotes are double (3.12+ allows it):
# 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. The only way to turn a user's text into an f-string is eval(), and then the user can run any code they like. Use str.format() with a fixed set of allowed keys instead.
  • f"{x = }" vs f"{x=}". Whitespace inside the braces is preserved literally. f"{x = }" produces 'x = 42', and 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 3-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:>3} @ ${price:>5.2f}"
The subtotal for the line is qty * price. Add it to the running total before you append the line.
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:>5.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. You don't add any spaces by hand. The format specs do all the padding.

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 works inside {}: arithmetic, method calls, indexing.
  • Format specs after : handle width (>10, <10, ^10), decimals (.2f), thousands (,), percent (.1%), scientific (.2e), zero-padding (02d).
  • f"{x=}" prints x=value. It is the shortest way to print a variable while debugging.
  • 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 collection that you will use in most Python programs.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.