Python String Formatting: f-strings, format(), and % Operator

String formatting is one of the most common operations in Python programming. Whether you're logging application events, generating user-facing messages, or constructing SQL queries, how you format...

Key Insights

  • F-strings are 20-30% faster than format() and should be your default choice for Python 3.6+ projects due to their superior readability and performance
  • The % operator remains useful for logging and legacy codebases, but avoid it in new code—it’s error-prone and less flexible than modern alternatives
  • Use str.format() when you need template strings that are reused multiple times or when formatting patterns are loaded from configuration files

Why String Formatting Matters

String formatting is one of the most common operations in Python programming. Whether you’re logging application events, generating user-facing messages, or constructing SQL queries, how you format strings affects code readability, maintainability, and performance.

Python offers three primary string formatting approaches, each with distinct syntax and use cases. Let’s start with a simple comparison:

name = "Alice"
age = 30
balance = 1234.567

# % operator (old-style)
message1 = "User: %s, Age: %d, Balance: $%.2f" % (name, age, balance)

# str.format() method
message2 = "User: {}, Age: {}, Balance: ${:.2f}".format(name, age, balance)

# f-strings (modern)
message3 = f"User: {name}, Age: {age}, Balance: ${balance:.2f}"

# All produce: "User: Alice, Age: 30, Balance: $1234.57"

All three methods produce identical output, but they differ significantly in readability, flexibility, and performance. Let’s examine each approach in detail.

The % Operator (Old-Style Formatting)

The % operator uses C-style printf formatting. While it’s the oldest method and considered legacy, you’ll encounter it frequently in existing codebases and Python’s logging module.

Basic syntax uses type specifiers: %s for strings, %d for integers, %f for floats:

# Basic interpolation
print("Hello, %s!" % "World")  # Hello, World!
print("Count: %d" % 42)  # Count: 42

# Multiple values require a tuple
print("%s has %d apples" % ("Bob", 5))  # Bob has 5 apples

# Formatting floats with precision
pi = 3.14159265359
print("Pi: %.2f" % pi)  # Pi: 3.14
print("Pi: %10.4f" % pi)  # Pi:     3.1416 (width 10, precision 4)

# Width and zero-padding
print("ID: %05d" % 42)  # ID: 00042

The dictionary-based syntax provides better readability for complex formatting:

user_data = {
    'username': 'john_doe',
    'email': 'john@example.com',
    'login_count': 127
}

message = "User %(username)s (%(email)s) has logged in %(login_count)d times" % user_data
print(message)
# User john_doe (john@example.com) has logged in 127 times

The main drawbacks: limited type safety (mixing up the order causes runtime errors), less readable syntax when mixing multiple types, and no support for custom object formatting without __str__ or __repr__.

The str.format() Method

Introduced in Python 2.6, str.format() addressed many limitations of the % operator. It uses curly braces {} as placeholders and offers powerful formatting options.

# Positional arguments
print("{} {} {}".format("one", "two", "three"))  # one two three

# Indexed arguments (can reuse and reorder)
print("{0} {2} {1}".format("one", "two", "three"))  # one three two
print("{0} {0} {0}".format("repeat"))  # repeat repeat repeat

# Named arguments
print("{name} is {age} years old".format(name="Carol", age=28))
# Carol is 28 years old

# Mixed approaches
print("{0} is {age} years old".format("Dave", age=35))
# Dave is 35 years old

The real power comes from format specifications:

# Number formatting
value = 1234567.89

print("{:,}".format(value))  # 1,234,567.89 (thousands separator)
print("{:.2f}".format(value))  # 1234567.89 (2 decimal places)
print("{:,.2f}".format(value))  # 1,234,567.89 (combined)

# Alignment and padding
print("{:<10}".format("left"))   # "left      " (left-aligned, width 10)
print("{:>10}".format("right"))  # "     right" (right-aligned)
print("{:^10}".format("center")) # "  center  " (centered)
print("{:*^10}".format("pad"))   # "***pad****" (centered with asterisks)

# Number bases
num = 255
print("Decimal: {0:d}, Hex: {0:x}, Octal: {0:o}, Binary: {0:b}".format(num))
# Decimal: 255, Hex: ff, Octal: 377, Binary: 11111111

The format() method shines when working with template strings:

# Template loaded from config or database
email_template = """
Dear {customer_name},

Your order #{order_id} totaling ${total:.2f} has been confirmed.
Estimated delivery: {delivery_date}

Thank you for your business!
"""

orders = [
    {'customer_name': 'Alice', 'order_id': 1001, 'total': 299.99, 'delivery_date': '2024-01-15'},
    {'customer_name': 'Bob', 'order_id': 1002, 'total': 149.50, 'delivery_date': '2024-01-16'},
]

for order in orders:
    print(email_template.format(**order))

F-Strings (Formatted String Literals)

F-strings, introduced in Python 3.6, are the modern standard for string formatting. They’re faster, more readable, and support inline expressions.

name = "Emma"
age = 32

# Basic interpolation
print(f"Hello, {name}!")  # Hello, Emma!
print(f"{name} is {age} years old")  # Emma is 32 years old

# Inline expressions
print(f"{name.upper()} is {age + 1} next year")  # EMMA is 33 next year
print(f"2 + 2 = {2 + 2}")  # 2 + 2 = 4

# Function calls
def calculate_tax(amount, rate=0.08):
    return amount * rate

price = 100
print(f"Price: ${price}, Tax: ${calculate_tax(price):.2f}")
# Price: $100, Tax: $8.00

# Method calls and complex expressions
items = [1, 2, 3, 4, 5]
print(f"Sum: {sum(items)}, Average: {sum(items)/len(items):.1f}")
# Sum: 15, Average: 3.0

F-strings support all format specifiers from str.format():

value = 1234.5678

print(f"{value:.2f}")     # 1234.57
print(f"{value:,.2f}")    # 1,234.57
print(f"{value:>15,.2f}") #      1,234.57
print(f"{value:*^20,.2f}")# ****1,234.57*****

# Date formatting
from datetime import datetime
now = datetime.now()
print(f"Current time: {now:%Y-%m-%d %H:%M:%S}")
# Current time: 2024-01-10 14:30:45

Python 3.8 added the debug specifier, which is incredibly useful:

x = 10
y = 20

print(f"{x=}, {y=}, {x+y=}")  # x=10, y=20, x+y=30

# Combines with format specs
pi = 3.14159
print(f"{pi=:.2f}")  # pi=3.14

Performance Comparison

F-strings are consistently faster than alternatives. Here’s a benchmark:

import timeit

name = "Performance"
value = 42

# Test functions
def test_percent():
    return "Name: %s, Value: %d" % (name, value)

def test_format():
    return "Name: {}, Value: {}".format(name, value)

def test_fstring():
    return f"Name: {name}, Value: {value}"

# Run benchmarks (1 million iterations)
percent_time = timeit.timeit(test_percent, number=1000000)
format_time = timeit.timeit(test_format, number=1000000)
fstring_time = timeit.timeit(test_fstring, number=1000000)

print(f"% operator: {percent_time:.4f}s")
print(f"format():   {format_time:.4f}s")
print(f"f-strings:  {fstring_time:.4f}s")

# Typical results:
# % operator: 0.2847s
# format():   0.3124s
# f-strings:  0.2156s

F-strings are approximately 20-30% faster because they’re evaluated at runtime as expressions rather than method calls.

Best Practices & When to Use Each

Use f-strings by default for Python 3.6+:

# Clear, concise, and fast
user = "admin"
action = "login"
timestamp = "2024-01-10"
print(f"[{timestamp}] User '{user}' performed action: {action}")

Use str.format() for templates:

# Template strings that are reused or loaded from external sources
SQL_TEMPLATE = "SELECT * FROM {table} WHERE {column} = {value}"

queries = [
    SQL_TEMPLATE.format(table="users", column="id", value=1),
    SQL_TEMPLATE.format(table="orders", column="status", value="'pending'"),
]

Use % operator only for logging (since Python’s logging module uses it):

import logging

logging.basicConfig(level=logging.INFO)
user_id = 12345
logging.info("User %d logged in", user_id)  # Standard logging convention

Avoid common pitfalls:

# DON'T: Build SQL queries with f-strings (SQL injection risk)
user_input = "admin' OR '1'='1"
query = f"SELECT * FROM users WHERE username = '{user_input}'"  # DANGEROUS!

# DO: Use parameterized queries
query = "SELECT * FROM users WHERE username = ?"
cursor.execute(query, (user_input,))

# DON'T: Format sensitive data in production logs
password = "secret123"
logging.info(f"Password: {password}")  # Security risk!

Conclusion

F-strings should be your default choice for string formatting in modern Python. They’re faster, more readable, and support powerful inline expressions. Reserve str.format() for template strings that need to be stored separately from your code, and only use the % operator when maintaining legacy code or working with Python’s logging module.

The evolution from % to format() to f-strings reflects Python’s commitment to readability and developer experience. By choosing the right tool for each situation, you’ll write code that’s both performant and maintainable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.