f-string evolution: from % to {x=}
Python has had four string formatting systems. Only one is still worth writing.
1name = "Alice"2score = 42.765434# % formatting (Python 1.x): C-style, confusing width/precision syntax5print("%-10s: %.2f" % (name, score))67# str.format() (Python 2.6): verbose, hard to read with many values8print("{:<10}: {:.2f}".format(name, score))910# f-strings (Python 3.6): inline expressions, direct and readable11print(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.
1# Before 3.8: the tedious way to debug2x = 423result = x * 3.144print("x =", x)5print("result =", result)67# After 3.8: = suffix does both in one shot8x = 429result = x * 3.1410print(f"{x=}")11print(f"{result=}")12print(f"{x=}, {result=:.2f}")
Expressions inside f-strings
1from datetime import date23items = [1, 2, 3, 4, 5]4today = date.today()56# Any expression works between the braces7print(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).
1url = "https://example.com/api/users"2filename = "report_2024.pdf"34# Before 3.9: fragile, hardcoded offsets5protocol = "https://"6if url.startswith(protocol):7 url_without_protocol = url[len(protocol):]8else:9 url_without_protocol = url1011# Also the wrong tool: lstrip strips *characters*, not a prefix string12# "https://".lstrip("https://") -- would remove 'h', 't', 'p', 's', ':', '/' individually13# so "http://example.com" would also be stripped -- silent bug1415# After 3.9: does exactly what it says16url_without_protocol = url.removeprefix("https://")17filename_no_ext = filename.removesuffix(".pdf")1819print(url_without_protocol) # example.com/api/users20print(filename_no_ext) # report_20242122# Safe when prefix/suffix is absent: returns original string unchanged23print("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.
1# Practical: cleaning log line prefixes2lines = [3 "[ERROR] disk full",4 "[WARN] low memory",5 "[INFO] server started",6 "raw line without prefix",7]89cleaned = [line.removeprefix("[ERROR] ")10 .removeprefix("[WARN] ")11 .removeprefix("[INFO] ")12 for line in lines]1314for 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.
1revenue = 1_234_567.892ratio = 0.042673count = 4245# Thousands separator6print(f"{revenue:,.2f}") # 1,234,567.8978# Percentage9print(f"{ratio:.1%}") # 4.3%1011# Zero-padded integers12print(f"{count:05d}") # 000421314# Scientific notation15print(f"{revenue:e}") # 1.234568e+061617# Right-aligned in a field18for 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:
1# Old: squinting at digit groups2max_connections = 10000003file_size_bytes = 1048576045# Modern: visual grouping6max_connections = 1_000_0007file_size_bytes = 10_485_76089# Works for hex, binary, octal too10color = 0xFF_A5_00 # orange as hex11flags = 0b0001_1100 # binary nibble grouping1213print(f"connections: {max_connections:,}") # 1,000,00014print(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.
1# Pre-3.12: can't use " inside f"..." -- SyntaxError2names = ["Alice", "Bob", "Charlie"]34# Workaround 1: pre-compute5joined = ", ".join(names)6print(f"Users: {joined}")78# Workaround 2: use a dict-style access with different quotes9data = {"key": "value"}10print(f"Value: {data['key']}") # single inside double: fine1112# Workaround 3: use a backslash escape -- also a SyntaxError before 3.1213# print(f"Users: {', '.join(names)}")
1# Python 3.12: any quotes inside any f-string -- just works2names = ["Alice", "Bob", "Charlie"]3data = {"key": "value"}45print(f"Users: {', '.join(names)}")6print(f'Value: {data["key"]}')78# Nested f-strings work too9scores = {"Alice": 95, "Bob": 87}10report = f"Top: {f'{max(scores, key=scores.get)} ({max(scores.values())})'}"11print(report)
Multiline f-strings for templates
1user = {"name": "Alice", "role": "admin", "last_login": "2026-01-15"}23# Clean multiline f-string -- no concatenation needed4email_body = f"""5Hello {user["name"]},67Your account ({user["role"]}) last logged in on {user["last_login"]}.89Regards,10The Team11""".strip()1213print(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.