Python String Formatting & F-Strings: The Complete Guide for 2026
Python has evolved through three eras of string formatting: the % operator inherited from C, the str.format() method introduced in Python 2.6, and f-strings (formatted string literals) added in Python 3.6. Each generation improved readability and power. Today, f-strings are the standard, and Python 3.12 made them even more flexible. This guide covers every formatting technique you will need, from basic interpolation through the format specification mini-language, number formatting, date/time, debugging tricks, and real-world patterns.
1. The Evolution of String Formatting
Understanding all three methods matters because you will encounter legacy code using % and .format(), and certain edge cases still favor them.
% Formatting (printf-style)
The oldest method, borrowed from C's printf. Available since Python 1.x:
# Basic substitution
name = "Alice"
age = 30
print("Hello, %s! You are %d years old." % (name, age))
# Hello, Alice! You are 30 years old.
# Named placeholders with a dictionary
print("%(name)s is %(age)d" % {"name": "Bob", "age": 25})
# Common type codes: %s (string), %d (integer), %f (float), %x (hex)
print("Pi is approximately %.4f" % 3.14159) # Pi is approximately 3.1416
print("Hex: %x, Oct: %o" % (255, 255)) # Hex: ff, Oct: 377
Downsides: Tuples break with single-element ("%s" % (value,) needs trailing comma), limited expression support, verbose with many variables.
str.format() Method
Introduced in Python 2.6 / 3.0 (PEP 3101). More readable and flexible than %:
# Positional arguments
print("Hello, {}! You are {} years old.".format("Alice", 30))
# Numbered arguments (reusable)
print("{0} scored {1}. {0} wins!".format("Alice", 95))
# Named arguments
print("{name} is {age} years old".format(name="Alice", age=30))
# Accessing attributes and items
point = (3, 7)
print("x={0[0]}, y={0[1]}".format(point))
# Object attributes
import datetime
d = datetime.date(2026, 2, 13)
print("{:%B %d, %Y}".format(d)) # February 13, 2026
F-Strings (Python 3.6+, PEP 498)
The modern standard. Prefix a string with f and embed expressions directly in braces:
name = "Alice"
age = 30
print(f"Hello, {name}! You are {age} years old.")
# Any expression works inside the braces
print(f"Next year you'll be {age + 1}")
print(f"Name uppercase: {name.upper()}")
print(f"Is adult: {age >= 18}")
print(f"Items: {', '.join(['a', 'b', 'c'])}")
2. F-String Fundamentals
F-strings evaluate expressions at runtime and embed the result into the string. They support anything that produces a value:
# Variables and expressions
x, y = 10, 20
print(f"Sum: {x + y}, Product: {x * y}")
# Method calls
text = "hello world"
print(f"Title: {text.title()}") # Title: Hello World
print(f"Words: {len(text.split())}") # Words: 2
# Attribute access
import math
print(f"Pi: {math.pi:.6f}") # Pi: 3.141593
# Ternary expressions
score = 85
print(f"Grade: {'Pass' if score >= 60 else 'Fail'}")
# Function calls
def greet(n):
return f"Hi, {n}!"
print(f"Message: {greet('Bob')}") # Message: Hi, Bob!
# List/dict access
users = ["Alice", "Bob", "Charlie"]
config = {"host": "localhost", "port": 8080}
print(f"First user: {users[0]}")
print(f"Host: {config['host']}:{config['port']}")
3. The Format Specification Mini-Language
The format spec follows the colon inside braces: f"{value:spec}". The full syntax is:
# Syntax: [[fill]align][sign][z][#][0][width][grouping][.precision][type]
# ALIGNMENT: < (left), > (right), ^ (center)
name = "Python"
print(f"|{name:<20}|") # |Python |
print(f"|{name:>20}|") # | Python|
print(f"|{name:^20}|") # | Python |
# FILL CHARACTER (any character before the alignment)
print(f"|{name:*<20}|") # |Python**************|
print(f"|{name:*>20}|") # |**************Python|
print(f"|{name:-^20}|") # |-------Python-------|
print(f"|{name:.^20}|") # |.......Python.......|
# WIDTH (minimum field width)
for item, price in [("Coffee", 4.5), ("Sandwich", 8.99), ("Cake", 12)]:
print(f"{item:<12} ${price:>6.2f}")
# Coffee $ 4.50
# Sandwich $ 8.99
# Cake $ 12.00
# SIGN: + (always show), - (default, negative only), space (space for positive)
num = 42
print(f"{num:+d}") # +42
print(f"{-num:+d}") # -42
print(f"{num: d}") # 42 (space before positive numbers)
Type Codes
# INTEGER types
n = 255
print(f"Decimal: {n:d}") # 255
print(f"Binary: {n:b}") # 11111111
print(f"Octal: {n:o}") # 377
print(f"Hex (lc): {n:x}") # ff
print(f"Hex (UC): {n:X}") # FF
# With prefix using #
print(f"Binary: {n:#b}") # 0b11111111
print(f"Octal: {n:#o}") # 0o377
print(f"Hex: {n:#x}") # 0xff
# FLOAT types
pi = 3.14159265
print(f"Fixed: {pi:f}") # 3.141593 (default 6 decimals)
print(f"2 decimals: {pi:.2f}") # 3.14
print(f"Scientific: {pi:e}") # 3.141593e+00
print(f"Sci 2 dec: {pi:.2e}") # 3.14e+00
print(f"General: {pi:g}") # 3.14159 (removes trailing zeros)
print(f"Percentage: {0.856:%}") # 85.600000%
print(f"Pct 1 dec: {0.856:.1%}") # 85.6%
# STRING type
print(f"{'Hello':s}") # Hello (default for strings)
print(f"{'Hello':.3s}") # Hel (truncate to 3 chars)
4. Number Formatting
Formatting numbers is one of the most common f-string tasks. Here is every pattern you need:
# THOUSANDS SEPARATORS
big = 1234567890
print(f"{big:,}") # 1,234,567,890
print(f"{big:_}") # 1_234_567_890
# CURRENCY
price = 1299.5
print(f"${price:,.2f}") # $1,299.50
print(f"EUR {price:,.2f}") # EUR 1,299.50
# PADDING WITH ZEROS
order_id = 42
print(f"ORD-{order_id:06d}") # ORD-000042
print(f"v{3:03d}.{8:03d}") # v003.008
# PERCENTAGE
ratio = 0.8567
print(f"{ratio:.1%}") # 85.7%
print(f"{ratio:.0%}") # 86%
# FIXED POINT precision
value = 3.14159
print(f"{value:.2f}") # 3.14
print(f"{value:.4f}") # 3.1416
print(f"{value:.0f}") # 3
# SCIENTIFIC NOTATION
large = 6.022e23
print(f"{large:.3e}") # 6.022e+23
print(f"{large:.3E}") # 6.022E+23
# BINARY / HEX for bytes and colors
r, g, b = 255, 128, 0
print(f"#{r:02x}{g:02x}{b:02x}") # #ff8000
print(f"Permissions: {0o755:b}") # 111101101
# SIGNIFICANT FIGURES with g
print(f"{0.00012345:.3g}") # 0.000123
print(f"{12345.6789:.5g}") # 12346
print(f"{100.0:.3g}") # 100
5. Date and Time Formatting
F-strings work directly with datetime objects using strftime codes after the colon:
from datetime import datetime, date, timedelta
now = datetime(2026, 2, 13, 14, 30, 45)
# Common date formats
print(f"{now:%Y-%m-%d}") # 2026-02-13
print(f"{now:%d/%m/%Y}") # 13/02/2026
print(f"{now:%B %d, %Y}") # February 13, 2026
print(f"{now:%b %d, %Y}") # Feb 13, 2026
# Time formats
print(f"{now:%H:%M:%S}") # 14:30:45
print(f"{now:%I:%M %p}") # 02:30 PM
# Combined date and time
print(f"{now:%Y-%m-%d %H:%M}") # 2026-02-13 14:30
print(f"{now:%A, %B %d at %I:%M %p}") # Friday, February 13 at 02:30 PM
# ISO format
print(f"{now:%Y-%m-%dT%H:%M:%S}") # 2026-02-13T14:30:45
# Day of week, week number
print(f"{now:%A}") # Friday
print(f"Week {now:%U} of {now:%Y}") # Week 06 of 2026
# Logging timestamps
print(f"[{now:%Y-%m-%d %H:%M:%S}] Server started")
# Relative dates
deadline = now + timedelta(days=7)
print(f"Due by {deadline:%b %d}") # Due by Feb 20
6. Debugging with f-strings: The = Specifier
Python 3.8 introduced the = specifier, which prints both the expression and its value. This is one of the most useful debugging features in Python:
# Basic variable debugging
x = 42
name = "Alice"
print(f"{x=}") # x=42
print(f"{name=}") # name='Alice'
# Expression debugging
items = [1, 2, 3, 4, 5]
print(f"{len(items)=}") # len(items)=5
print(f"{sum(items)=}") # sum(items)=15
print(f"{items[0]=}") # items[0]=1
# With format specs
price = 19.99
print(f"{price=:.2f}") # price=19.99
print(f"{price * 1.1=:.2f}") # price * 1.1=21.99
# Spacing around the =
print(f"{x = }") # x = 42 (preserves spaces)
print(f"{name = !r}") # name = 'Alice'
# Complex expressions
data = {"users": 150, "active": 89}
print(f"{data['active'] / data['users']:.1%=}")
# data['active'] / data['users']:.1%=59.3%
# Great for quick debugging loops
for i in range(3):
print(f"{i=}, {i**2=}, {i**3=}")
# i=0, i**2=0, i**3=0
# i=1, i**2=1, i**3=1
# i=2, i**2=4, i**3=8
# Conversion flags with =
value = "hello\nworld"
print(f"{value=}") # value='hello\nworld'
print(f"{value=!r}") # value='hello\nworld' (repr)
print(f"{value=!s}") # value=hello\nworld (str)
print(f"{value=!a}") # value='hello\nworld' (ascii)
7. Multiline and Raw F-Strings
# MULTILINE with triple quotes
user = {"name": "Alice", "role": "Admin", "email": "alice@dev.io"}
message = f"""
User Report
-----------
Name: {user['name']}
Role: {user['role']}
Email: {user['email']}
"""
print(message)
# IMPLICIT CONCATENATION (keeps lines short, no extra newlines)
greeting = (
f"Hello {user['name']}, "
f"you are logged in as {user['role']}. "
f"Contact: {user['email']}"
)
# RAW F-STRINGS (rf or fr prefix) -- no escape processing
path = "Documents"
print(rf"C:\Users\{name}\{path}") # C:\Users\Alice\Documents
# Without r: backslashes would be escape sequences
# FR also works (case insensitive prefix)
print(FR"Path: C:\{path}\data")
# Multiline raw f-string
regex_info = rf"""
Pattern: \d+\.\d+
Matches: {42.5}
Flags: re.MULTILINE
"""
8. Python 3.12+ F-String Improvements (PEP 701)
Python 3.12 removed several long-standing f-string limitations, making them truly first-class expressions:
# 1. REUSE THE SAME QUOTE TYPE (new in 3.12)
# Before 3.12, you had to alternate quote types:
user = {"name": "Alice"}
# Old way: f"Name: {user['name']}" (single inside double)
# New way (3.12+):
print(f"Name: {user["name"]}") # Same quotes, now legal!
# 2. NESTED F-STRINGS with same quotes
songs = ["Yesterday", "Imagine", "Bohemian Rhapsody"]
print(f"Favorite: {f"{songs[0]}" if songs else "None"}")
# 3. BACKSLASHES inside f-string expressions
# Before 3.12: SyntaxError
# Now valid:
items = ["a", "b", "c"]
print(f"Joined: {'\n'.join(items)}")
# Joined: a
# b
# c
newline = "first\nsecond"
print(f"Lines: {newline.split('\n')}")
# 4. COMMENTS in multiline f-string expressions (3.12+)
result = f"{
x # the x value
+ y # plus y
}"
# 5. UNLIMITED NESTING DEPTH
# Python 3.12 removed the arbitrary nesting limit
print(f"{'='*20}") # Always worked
print(f"{f"{f"{42}"}"}") # Deeply nested -- now valid
9. Performance Comparison
F-strings are the fastest formatting method. Here is a benchmark comparison:
import timeit
name = "Alice"
age = 30
n = 1_000_000
# F-string
t1 = timeit.timeit(lambda: f"{name} is {age} years old", number=n)
# str.format()
t2 = timeit.timeit(lambda: "{} is {} years old".format(name, age), number=n)
# % formatting
t3 = timeit.timeit(lambda: "%s is %d years old" % (name, age), number=n)
# Concatenation
t4 = timeit.timeit(lambda: name + " is " + str(age) + " years old", number=n)
# Typical results (Python 3.12, relative to f-string = 1.0x):
# f-string: 1.0x (fastest)
# % formatting: 1.2x
# str.format(): 1.5x
# concatenation: 1.3x
f"{name} is {age}" compiles to a series of LOAD and FORMAT_VALUE instructions, avoiding the overhead of a method call (.format()) or tuple creation (%). For hot loops and high-throughput code, f-strings measurably reduce CPU time.
10. Common Real-World Patterns
Table Output
headers = ["Name", "Role", "Salary"]
rows = [
("Alice", "Engineer", 95000),
("Bob", "Designer", 82000),
("Charlie", "Manager", 110000),
]
# Header
print(f"{'Name':<12} {'Role':<12} {'Salary':>10}")
print("-" * 36)
for name, role, salary in rows:
print(f"{name:<12} {role:<12} ${salary:>9,}")
# Output:
# Name Role Salary
# ------------------------------------
# Alice Engineer $ 95,000
# Bob Designer $ 82,000
# Charlie Manager $ 110,000
File Sizes (Human-Readable)
def format_size(size_bytes: int) -> str:
"""Convert bytes to human-readable file size."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} PB"
print(format_size(1536)) # 1.5 KB
print(format_size(2_500_000)) # 2.4 MB
print(format_size(5_000_000_000)) # 4.7 GB
Progress Bars
def progress_bar(current: int, total: int, width: int = 30) -> str:
pct = current / total
filled = int(width * pct)
bar = "#" * filled + "-" * (width - filled)
return f"\r[{bar}] {pct:.0%} ({current}/{total})"
# Usage in a loop
import time
for i in range(101):
print(progress_bar(i, 100), end="", flush=True)
time.sleep(0.02)
# [##############################] 100% (100/100)
Padding and Alignment
# Menu / receipt layout
items = [("Latte", 4.50), ("Croissant", 3.25), ("Muffin", 2.99)]
print("=" * 30)
for item, price in items:
print(f" {item:. <22} ${price:.2f}")
total = sum(p for _, p in items)
print("-" * 30)
print(f" {'TOTAL':. <22} ${total:.2f}")
# Hex dump
data = b"\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64"
for i in range(0, len(data), 8):
chunk = data[i:i+8]
hex_part = " ".join(f"{b:02x}" for b in chunk)
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f"{i:08x} {hex_part:<24} |{ascii_part}|")
# 00000000 48 65 6c 6c 6f 20 57 6f |Hello Wo|
# 00000008 72 6c 64 |rld|
Currency Formatting
amount = 1234567.891
# US Dollar
print(f"${amount:,.2f}") # $1,234,567.89
# Negative amounts
loss = -4500.00
print(f"{loss:+,.2f}") # -4,500.00
print(f"({abs(loss):,.2f})") # (4,500.00) -- accounting style
# Padded for reports
for label, val in [("Revenue", 50000), ("Expenses", -32500), ("Profit", 17500)]:
print(f" {label:<12} {'$':>1}{val:>12,.2f}")
11. Template Strings: When F-Strings Are Not Enough
The string.Template class is the safe choice when formatting user-supplied template strings. Unlike f-strings, Template strings cannot execute arbitrary code:
from string import Template
# User-supplied template -- safe because it cannot call functions
template = Template("Hello, $name! You have $count messages.")
result = template.substitute(name="Alice", count=5)
print(result) # Hello, Alice! You have 5 messages.
# safe_substitute ignores missing keys instead of raising
t = Template("$greeting, $name!")
print(t.safe_substitute(greeting="Hi"))
# Hi, $name! (missing key left as-is)
# WHEN TO USE TEMPLATE vs F-STRING:
# - F-string: developer-controlled format strings (trusted code)
# - Template: user-supplied templates (untrusted input)
# - .format(): NEVER with user input! .format() can leak data:
# "{0.__class__.__mro__}".format(obj) # Exposes internals!
# Template supports ${var} for adjacent text
t = Template("File: ${name}_backup.tar.gz")
print(t.substitute(name="database")) # File: database_backup.tar.gz
12. Best Practices and Common Gotchas
# GOTCHA 1: Braces in f-strings -- double them to escape
print(f"Set notation: {{{1, 2, 3}}}") # Set notation: {1, 2, 3}
print(f"JSON: {{\"key\": \"{name}\"}}") # JSON: {"key": "Alice"}
# GOTCHA 2: Backslashes before Python 3.12
# In Python < 3.12, you cannot use backslashes inside f-string expressions:
# print(f"{'\\n'.join(items)}") # SyntaxError in < 3.12
# Workaround: assign to a variable first
nl = "\n"
print(f"{nl.join(items)}") # Works in all versions
# GOTCHA 3: Single-element tuple with %
value = "test"
# print("Value: %s" % value) # Works
# print("Value: %s" % (value)) # Works BUT risky if value is a tuple
# print("Value: %s" % (value,)) # Always safe -- trailing comma
# BEST PRACTICE 1: Prefer f-strings for all new code
# Bad: "Hello, %s" % name
# Bad: "Hello, {}".format(name)
# Good: f"Hello, {name}"
# BEST PRACTICE 2: Keep expressions simple
# Bad: f"{db.query(User).filter(active=True).order_by('name').first().name}"
# Good:
user = db.query(User).filter(active=True).order_by("name").first()
print(f"User: {user.name}")
# BEST PRACTICE 3: Use !r for debugging strings (shows quotes and escapes)
path = "/tmp/my file.txt"
print(f"Loading {path!r}") # Loading '/tmp/my file.txt'
# BEST PRACTICE 4: Use conversion flags appropriately
# !s -- calls str() on the value (default)
# !r -- calls repr() on the value (debugging)
# !a -- calls ascii() on the value (escapes non-ASCII)
emoji = "Hello \U0001f600"
print(f"{emoji!a}") # 'Hello \U0001f600'
# BEST PRACTICE 5: Format spec for consistent output
# Always specify .2f for money, leading zeros for IDs
print(f"Price: ${19.9:.2f}") # Price: $19.90
print(f"Order #{42:08d}") # Order #00000042
Frequently Asked Questions
What are Python f-strings and when were they introduced?
F-strings (formatted string literals) were introduced in Python 3.6 via PEP 498. They let you embed expressions directly inside string literals by prefixing the string with f or F and placing expressions inside curly braces. For example, f"Hello, {name}!" evaluates the variable name and inserts the result. F-strings are the recommended way to format strings in modern Python because they are readable, concise, and faster than both % formatting and str.format().
How do I format numbers with commas in Python?
Use the comma format specifier inside an f-string: f"{1234567:,}" produces 1,234,567. For floats with commas and fixed decimal places, use f"{amount:,.2f}" which gives 1,234,567.89. You can also use underscores with f"{value:_}" for 1_234_567, which is common in code for readability. These format specifiers work identically in f-strings, str.format(), and the built-in format() function.
What does f'{variable=}' do in Python?
The = specifier, introduced in Python 3.8, is a debugging feature that prints both the expression text and its value. Writing f"{x=}" produces the string x=42 if x is 42. It works with any expression: f"{len(items)=}" outputs len(items)=5. You can combine it with format specs like f"{price=:.2f}" to get price=19.99. This eliminates the tedious pattern of writing f"x={x}" manually.
Are f-strings faster than .format() and % formatting?
Yes, f-strings are the fastest string formatting method in Python. In benchmarks, f-strings are typically 30-60% faster than str.format() and 10-30% faster than % formatting. This is because f-strings are evaluated at compile time and converted to efficient bytecode, while .format() requires a method call and argument parsing at runtime. For hot loops and high-throughput code, f-strings measurably reduce CPU time.
How do I use multiline f-strings in Python?
Use triple quotes for multiline f-strings: f"""...""" or f'''...'''. Each line can contain expressions in curly braces. You can also use parentheses to break a single f-string across lines without triple quotes: message = (f"Hello {name}, " f"you have {count} " f"notifications."). Adjacent f-strings are concatenated automatically by Python, which keeps each line short and readable.
Can I use f-strings with dictionaries and complex expressions?
Yes, f-strings support any valid Python expression including dictionary access, method calls, list comprehensions, ternary operators, and function calls. For dictionaries, use different quote types inside and outside: f"{user['name']}" inside double-quoted f-string. In Python 3.12+, you can reuse the same quote style thanks to PEP 701: f"{user["name"]}" is now valid. Complex expressions like f"{', '.join(items)}" and ternary operators also work.
Conclusion
F-strings are the clear winner for string formatting in modern Python. They are faster, more readable, and more concise than % formatting and str.format(). The format specification mini-language gives you precise control over alignment, padding, number formatting, and type conversion. Python 3.8 added the invaluable = debugging specifier, and Python 3.12 removed the last remaining limitations around quotes, backslashes, and nesting. Use string.Template only when handling user-supplied format strings, and always prefer f-strings for everything else.
Related Resources
- Python Beginners Guide — fundamentals, syntax, and getting started
- Python Type Hints Guide — typing your functions and data structures
- Python Logging Guide — formatting log messages with the logging module
- Python Dataclasses Guide — __repr__ and __str__ with formatted output
- Python Data Structures Guide — formatting lists, dicts, sets, and tuples
- Python Decorators Guide — advanced function patterns and wrapping