Codetail

Article 4 of 8

f-Strings and the String Renaissance

% and .format() are history. Here is what replaced them.

16 min read

f-string evolution: from % to {x=}

Python has had four string formatting systems. Only one is still worth writing.

Python
1name = "Alice"
2score = 42.7654
3
4# % formatting (Python 1.x): C-style, confusing width/precision syntax
5print("%-10s: %.2f" % (name, score))
6
7# str.format() (Python 2.6): verbose, hard to read with many values
8print("{:<10}: {:.2f}".format(name, score))
9
10# f-strings (Python 3.6): inline expressions, direct and readable
11print(f"{name:<10}: {score:.2f}")

The debug shorthand: f"{x=}" (3.8)

Adding = after a variable name prints both the expression and its value. Saves you from writing the variable name twice every time you debug.

Python
1# Before 3.8: the tedious way to debug
2x = 42
3result = x * 3.14
4print("x =", x)
5print("result =", result)
6
7# After 3.8: = suffix does both in one shot
8x = 42
9result = x * 3.14
10print(f"{x=}")
11print(f"{result=}")
12print(f"{x=}, {result=:.2f}")

Expressions inside f-strings

Python
1from datetime import date
2
3items = [1, 2, 3, 4, 5]
4today = date.today()
5
6# Any expression works between the braces
7print(f"total: {sum(items)}")
8print(f"average: {sum(items) / len(items):.1f}")
9print(f"year: {today.year}")
10print(f"upper: {'hello world'.upper()}")

removeprefix and removesuffix (3.9)

Stripping a known prefix or suffix used to require either a slice with a hardcoded length, or a conditional plus lstrip (which has different semantics and is easy to misuse).

Python
1url = "https://example.com/api/users"
2filename = "report_2024.pdf"
3
4# Before 3.9: fragile, hardcoded offsets
5protocol = "https://"
6if url.startswith(protocol):
7 url_without_protocol = url[len(protocol):]
8else:
9 url_without_protocol = url
10
11# Also the wrong tool: lstrip strips *characters*, not a prefix string
12# "https://".lstrip("https://") -- would remove 'h', 't', 'p', 's', ':', '/' individually
13# so "http://example.com" would also be stripped -- silent bug
14
15# After 3.9: does exactly what it says
16url_without_protocol = url.removeprefix("https://")
17filename_no_ext = filename.removesuffix(".pdf")
18
19print(url_without_protocol) # example.com/api/users
20print(filename_no_ext) # report_2024
21
22# Safe when prefix/suffix is absent: returns original string unchanged
23print("example.com".removeprefix("https://")) # example.com

The critical difference from lstrip: lstrip strips any combination of the given characters, character by character. removeprefix strips the exact string, once, and only if it matches. They are not interchangeable.

Python
1# Practical: cleaning log line prefixes
2lines = [
3 "[ERROR] disk full",
4 "[WARN] low memory",
5 "[INFO] server started",
6 "raw line without prefix",
7]
8
9cleaned = [line.removeprefix("[ERROR] ")
10 .removeprefix("[WARN] ")
11 .removeprefix("[INFO] ")
12 for line in lines]
13
14for line in cleaned:
15 print(line)

Number formatting in f-strings

f-strings inherit the full format spec from str.format(), which means you can express complex number formatting without importing anything.

Python
1revenue = 1_234_567.89
2ratio = 0.04267
3count = 42
4
5# Thousands separator
6print(f"{revenue:,.2f}") # 1,234,567.89
7
8# Percentage
9print(f"{ratio:.1%}") # 4.3%
10
11# Zero-padded integers
12print(f"{count:05d}") # 00042
13
14# Scientific notation
15print(f"{revenue:e}") # 1.234568e+06
16
17# Right-aligned in a field
18for label, val in [("sales", 1234), ("returns", 56), ("net", 1178)]:
19 print(f"{label:>10}: {val:>6,}")

Underscore separators in numeric literals (3.6)

Not technically a string feature, but pairs naturally with number formatting. Underscores in numeric literals are purely cosmetic and ignored by Python:

Python
1# Old: squinting at digit groups
2max_connections = 1000000
3file_size_bytes = 10485760
4
5# Modern: visual grouping
6max_connections = 1_000_000
7file_size_bytes = 10_485_760
8
9# Works for hex, binary, octal too
10color = 0xFF_A5_00 # orange as hex
11flags = 0b0001_1100 # binary nibble grouping
12
13print(f"connections: {max_connections:,}") # 1,000,000
14print(f"hex: {color:#010x}") # 0x00ffa500

Multiline f-strings and nested quotes (3.12)

Before 3.12, you could not use the same quote character inside an f-string expression as the one delimiting the string itself. It caused awkward workarounds with temporary variables or different quote styles.

Python
1# Pre-3.12: can't use " inside f"..." -- SyntaxError
2names = ["Alice", "Bob", "Charlie"]
3
4# Workaround 1: pre-compute
5joined = ", ".join(names)
6print(f"Users: {joined}")
7
8# Workaround 2: use a dict-style access with different quotes
9data = {"key": "value"}
10print(f"Value: {data['key']}") # single inside double: fine
11
12# Workaround 3: use a backslash escape -- also a SyntaxError before 3.12
13# print(f"Users: {', '.join(names)}")
Python
1# Python 3.12: any quotes inside any f-string -- just works
2names = ["Alice", "Bob", "Charlie"]
3data = {"key": "value"}
4
5print(f"Users: {', '.join(names)}")
6print(f'Value: {data["key"]}')
7
8# Nested f-strings work too
9scores = {"Alice": 95, "Bob": 87}
10report = f"Top: {f'{max(scores, key=scores.get)} ({max(scores.values())})'}"
11print(report)

Multiline f-strings for templates

Python
1user = {"name": "Alice", "role": "admin", "last_login": "2026-01-15"}
2
3# Clean multiline f-string -- no concatenation needed
4email_body = f"""
5Hello {user["name"]},
6
7Your account ({user["role"]}) last logged in on {user["last_login"]}.
8
9Regards,
10The Team
11""".strip()
12
13print(email_body)

The .strip() at the end removes the leading and trailing newlines from the triple-quoted string itself, so the output starts cleanly. This is a reliable pattern for multiline f-string templates.