Compare the four Python string formatting methods (the % operator, .format(), the Python f-string, and Template strings) with tested examples, a real performance benchmark, and a decision flowchart that tells you which one to reach for.
“There must be a better way.”
Raymond Hettinger, PyCon 2013
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 16 minutes
You want to drop a variable into a string. Simple wish, right? Python gives you four completely different ways to do it. Four ways sits awkwardly next to the Zen of Python’s promise that there should be one obvious way to do things, but here we are. The good news is that for about 95 percent of the code you will ever write, the answer is the same: use an f-string. The other 5 percent is exactly why this post exists.
Think of it like buying a pen. You could use a fancy fountain pen, a ballpoint, a pencil, or a marker. They all write, but you grab the ballpoint for everyday notes and only reach for the marker when you need it on a whiteboard. Python string formatting is the same. One tool is your default, and the other three each have one job they do better. Instead of explaining each method on its own little island, we will line them up side by side. Same task, four solutions, one clear winner, then the handful of cases where the winner is the wrong call.
Table of Contents
The Decision Flowchart
Screenshot this. Pin it. Here’s how to choose in 10 seconds.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The flowchart maps out how to pick the right string formatting approach in Python: f-strings for most cases, .format() when you need a reusable template, percent formatting only in old code, and Template for untrusted input. The very first branch asks “Are you on Python 3.6 or later?” In 2026 the honest answer is always yes, so f-strings are your default tool. The branches below that one cover the edge cases you will run into as your projects get bigger.
Quick Comparison Table
| Criteria | f-string | .format() | % operator | Template |
|---|---|---|---|---|
| Introduced | Python 3.6 | Python 2.6 | Python 1.0 | Python 2.4 |
| Readability | ⭐ Best | Good | Poor for complex | Good |
| Performance | ⭐ Fastest | Medium | Fast | Slowest |
| Expressions | Yes | Limited | No | No |
| User input safe? | No | No | No | Yes |
| Dynamic templates | No | Yes | Limited | Yes |
| Use when | Default choice | Runtime templates | Legacy/logging | User templates |
f-strings (Python 3.6+)
f-strings are the modern standard, and once you use them you will not want to go back. You put an f right before the opening quote, drop any expression inside {}, and Python works it out on the spot. Readable, fast, and powerful, all at once. The “f” stands for “formatted”, and you can read f"Hi {name}" out loud almost like plain English. Think of an f-string like a wedding invitation with a blank for the guest’s name: the sentence is already printed, and the right name gets written into the blank as each card goes out.
In the example below, a developer named Rahul builds a small profile card, and we look up quiz scores for two students, Niranjan and Viraj.
📄 f_strings.py: the method you should use 95% of the time
name = "Rahul Mahadik"
age = 28
salary = 85000.50
# Basic variable insertion
print(f"Name: {name}")
print(f"Age: {age}")
# Expressions inside braces, anything goes
print(f"Age next year: {age + 1}")
print(f"Name uppercase: {name.upper()}")
print(f"Salary formatted: ₹{salary:,.2f}")
# Multi-line f-strings
profile = (
f"Developer: {name}\n"
f"Age: {age}\n"
f"Senior: {age >= 25}"
)
print(profile)
# f-strings with dictionaries
scores = {"Niranjan": 92, "Viraj": 88}
print(f"Niranjan scored {scores['Niranjan']}")
▶ Output
Name: Rahul Mahadik Age: 28 Age next year: 29 Name uppercase: RAHUL MAHADIK Salary formatted: ₹85,000.50 Developer: Rahul Mahadik Age: 28 Senior: True Niranjan scored 92
What happened here: An f-string runs the expressions inside the braces while your program is running, right there in the string. {age + 1} does real math. {name.upper()} calls a real method. {salary:,.2f} uses a format specifier to add the thousands commas and two decimal places. This is why f-strings won the argument. Everything sits inline where you can read it, and you never have to count positional arguments or hop back and forth between the string and a list of values.
str.format() Method
Before f-strings showed up, .format() was the method everyone reached for. It still earns its place when you need a dynamic template, that is, a string you store in a variable or load from a config file and fill in later. An f-string cannot do that, because it fills in its values the moment the line runs. Think of .format() like a rubber stamp with changeable letters: you keep one stamp and press different names into it all day. In the examples below, Pravin, Aditi, and Prathamesh are simply user names we stamp into greetings.
📄 format_method.py: still useful for dynamic templates
# Positional arguments
print("Hello, {}! You are {} years old.".format("Pravin", 27))
# Named arguments
print("Hello, {name}! City: {city}".format(name="Aditi", city="Mumbai"))
# Reuse arguments
print("{0} likes {1}. {0} also likes {2}.".format("Prathamesh", "Python", "cricket"))
# Dynamic templates, the key advantage over f-strings
template = "Welcome, {name}! Your role is {role}."
users = [
{"name": "Viraj", "role": "developer"},
{"name": "Niranjan", "role": "designer"},
]
for user in users:
print(template.format(**user))
▶ Output
Hello, Pravin! You are 27 years old. Hello, Aditi! City: Mumbai Prathamesh likes Python. Prathamesh also likes cricket. Welcome, Viraj! Your role is developer. Welcome, Niranjan! Your role is designer.
What happened here: The .format() method swaps each {} placeholder for an argument you pass in. The one thing it does that f-strings cannot: the template string can live in a variable, come from a file, or arrive as a function parameter, and you fill it in whenever you like. F-strings get evaluated the instant Python reads them, so they can never be reusable templates. Picture an app that pulls email templates out of a database. The template text is not in your source code, so .format() (or its cousin Template, which we meet soon) is the right tool.
% Operator (printf-style)
This is the oldest method of the four, borrowed straight from C’s printf. You will bump into it in older codebases and inside the logging module, so it is worth recognising. Think of it like a landline phone: newer options have replaced it almost everywhere, yet you will still find it working perfectly fine in older offices. For brand new code, though, there is almost no reason to pick it.
📄 percent_formatting.py: legacy method, still alive in logging
name = "Rahul"
age = 28
gpa = 3.85
# %s = string, %d = integer, %f = float
print("Name: %s, Age: %d" % (name, age))
print("GPA: %.2f" % gpa)
# Named placeholders with dict
print("%(name)s is %(age)d years old" % {"name": "Viraj", "age": 26})
# Why logging still uses this
import logging
logging.basicConfig(level=logging.INFO)
logging.info("User %s logged in, attempt %d", "Pravin", 3)
▶ Output
Name: Rahul, Age: 28 GPA: 3.85 Viraj is 26 years old INFO:root:User Pravin logged in, attempt 3
What happened here: The % operator drops values in by type: %s for a string, %d for an integer, %f for a float. The logging module sticks with this style on purpose. Notice that we passed the values as separate arguments to logging.info rather than building the string ourselves. That lets logging hold off on actually formatting the message until it knows the message will be shown. If the log level is turned off, the string never gets built at all, which saves CPU (Central Processing Unit) time.
In a hot code path that runs millions of times, skipping that work is a real win. (One small detail: the log line is written to standard error, while the other three lines go to standard output, so on some terminals you may see them appear in a slightly different order.)
Template Strings
This is the safest option when the template itself comes from a user. Template strings use plain $variable syntax and deliberately keep things boring: no code runs, no attribute access, nothing clever. That “boring on purpose” design is the whole point. Think of it like a fill-in-the-blanks form. The form only has labelled blanks, so whoever fills it in can drop in a name or a city but cannot smuggle in instructions for your program. In the example below we fill the blanks with details for a user named Anvay, then see what happens when a blank is left empty.
📄 template_strings.py: safe for user-supplied templates
from string import Template
# Basic usage
tmpl = Template("Hello, $name! You are $age years old.")
result = tmpl.substitute(name="Anvay", age=30)
print(result)
# safe_substitute does not crash on missing keys
tmpl2 = Template("Hello $name, welcome to $city!")
result2 = tmpl2.safe_substitute(name="Prathamesh")
print(result2) # $city stays as-is
# Why this matters: user-controlled templates
# f-string: f"{__import__('os').system('rm -rf /')}" <-- code execution!
# Template: Template("$name").substitute(name="safe") <-- just text
▶ Output
Hello, Anvay! You are 30 years old. Hello Prathamesh, welcome to $city!
What happened here: safe_substitute() left $city sitting there as plain text instead of throwing an error. That is handy when a template has optional fields that might not always get filled. The real prize, though, is safety. Say your app lets users write their own notification templates, like "Order $order_id shipped to $name". Template strings are the right call. F-strings and .format() both allow attribute access and arbitrary expressions, so a sneaky template from a user could reach into your objects and run code you never intended. Template strings simply cannot do that.
Format Specifiers
Format specifiers are the little instructions you write after a colon inside the braces, and the good news is they work the same way in both f-strings and .format(). They control alignment, padding, decimal places, and how numbers look. Think of a specifier like the settings screen on a printer: the document stays the same, you just choose the margins, the alignment, and how the numbers come out. Learn them once and you can format a tidy receipt, a price tag, or a percentage without any extra libraries.
📄 format_specifiers.py: alignment, padding, and number formatting
# Number formatting
price = 49999.5
print(f"Default: {price}")
print(f"Comma separator: {price:,.2f}")
print(f"Leading zeros: {42:05d}")
print(f"Percentage: {0.856:.1%}")
print(f"Binary: {255:08b}")
print(f"Hex: {255:#x}")
# Alignment and padding
students = [("Rahul", 95), ("Niranjan", 88), ("Viraj", 92)]
print(f"\n{'Name':<12} {'Score':>6}")
print("-" * 20)
for name, score in students:
print(f"{name:<12} {score:>6}")
# Truncation
long_text = "This is a very long description"
print(f"\nTruncated: {long_text:.15}")
▶ Output
Default: 49999.5 Comma separator: 49,999.50 Leading zeros: 00042 Percentage: 85.6% Binary: 11111111 Hex: 0xff Name Score -------------------- Rahul 95 Niranjan 88 Viraj 92 Truncated: This is a very
What happened here: The format specifier is whatever you write after the colon inside {}. :,.2f reads as “add thousands commas, keep 2 decimal places, treat it as a float.” :<12 means “left-align inside a 12-character-wide column.” :>6 means “right-align inside a 6-character-wide column.” That column trick is how the names and scores above line up into neat receipt columns. The specifiers behave the same whether you use f-strings or .format(), because both follow the same Format Specification Mini-Language.
Performance Comparison
Enough claims, let’s race them. A benchmark is like timing three routes to the same office: same start, same destination, and the stopwatch settles the argument. The script below builds the exact same string a million times with each method.
📄 benchmark.py: 1 million iterations each
import timeit
name, age = "Rahul", 28
n = 1_000_000
t_fstring = timeit.timeit(lambda: f"{name} is {age}", number=n)
t_format = timeit.timeit(lambda: "{} is {}".format(name, age), number=n)
t_percent = timeit.timeit(lambda: "%s is %d" % (name, age), number=n)
print(f"f-string: {t_fstring:.3f}s")
print(f".format(): {t_format:.3f}s")
print(f"% operator: {t_percent:.3f}s")
▶ Output (typical on Python 3.14.6)
f-string: 0.221s .format(): 0.407s % operator: 0.375s
What happened here: f-strings win because Python compiles them into tight bytecode when it first reads your file, instead of doing a method lookup every single time at runtime. Across a best-of-five measurement on Python 3.14.6, the f-string came out roughly 40 percent faster than .format() and over 30 percent faster than the % operator. Your exact numbers will wobble from run to run, since they depend on your machine and what else it is doing, but the ranking stays the same.
Honestly, in most apps this gap does not matter. You will never feel it unless you are formatting millions of strings in a loop. The real reason to pick f-strings is that readability and speed point in the same direction, so for everyday code there is just no reason to reach for anything else.
Common Mistakes
Mistake 1: Forgetting the f prefix
🚫 Silent bug
name = "Pravin"
greeting = "Hello, {name}!" # missing f prefix
print(greeting) # prints: Hello, {name}!
✅ Correct
name = "Pravin"
greeting = f"Hello, {name}!" # f prefix present
print(greeting) # prints: Hello, Pravin!
Mistake 2: Using .format() with user-controlled templates
🚫 Security risk
# User could inject: "{name.__class__.__mro__[1].__subclasses__()}"
user_template = get_template_from_user()
result = user_template.format(name="Anvi") # walks attributes you never meant to expose!
✅ Safe
from string import Template user_template = Template(get_template_from_user()) result = user_template.safe_substitute(name="Anvi") # only $var, no code
Why: The two snippets above are sketches, not runnable on their own, because get_template_from_user() stands in for whatever pulls text from a user, and "Anvi" is just a sample user name being filled in. The point is the danger. When the template string comes from outside your code, .format() lets it reach into your objects through dotted names like {name.__class__} and walk its way to data you never meant to expose. Template.safe_substitute() only understands plain $name blanks, so there is nothing to exploit. Outside input means Template, every time.
Decision Summary
- Use f-strings for everything by default. They are readable, fast, and expressive.
- Use .format() when the template string is a variable or loaded at runtime
- Use Template when the template comes from untrusted user input
- Use % only in legacy code or the
loggingmodule (lazy evaluation)
Practice Exercises
- Exercise 1: Use f-strings to display product name, quantity, and price as aligned receipt columns.
- Exercise 2: Create a multiplication table (1-10) with f-string fixed-width formatting.
- Exercise 3: Build a report formatter outputting an ASCII table with headers from a list of dicts.
Conclusion
f-strings won the Python string formatting contest, and it was not close. They are the fastest, the easiest to read, and the most capable option on any Python from 3.6 onward. The other three each survive for one specific reason: .format() for templates you fill in later, Template for text that comes from users, and % for old code and the logging module. Learn to recognise all four, and then happily reach for f-strings about 95 percent of the time.
Next up: Taking User Input. We cover the input() function, type casting, and building input validation loops that do not fall over when someone types nonsense. And if you want the full roadmap, from these fundamentals all the way to the AI/ML chapters, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
Which Python string formatting method is fastest?
f-strings are the fastest string formatting method in Python 3.6+. Python compiles them into efficient bytecode when it first reads your file, so in a best-of-five benchmark on Python 3.14.6 they came out around 40% faster than .format() and over 30% faster than % formatting. Exact numbers vary by machine, but the ranking is consistent.
Can I use expressions inside f-strings?
Yes. f-strings evaluate any valid Python expression inside the curly braces: f"{2 + 3}", f"{name.upper()}", and even f"{len(items)}" all work. Keep the expressions simple though. If something needs more than one method call, pull it out into a variable first so the line stays easy to read.
When should I use .format() instead of f-strings?
Use .format() when the template string is stored in a variable, loaded from a file, or defined at a different time than the values. f-strings are evaluated immediately at definition, so they cannot be used as reusable templates.
Are f-strings safe for user input?
f-strings themselves are safe because they are defined in your source code. The security risk is with .format() and f-strings when the template string comes from untrusted user input. For user-supplied templates, use string.Template which only allows simple $variable substitution.
Why does Python logging use % formatting?
The logging module uses % formatting for lazy evaluation. The string is only formatted if the log message actually gets emitted. If the log level is disabled, the formatting work is skipped entirely, saving CPU in performance-critical code.
How do I include literal braces in an f-string?
Double them: f"{{not a variable}}" produces the string {not a variable}. The double braces {{}} are the escape sequence for literal curly braces inside f-strings and .format() strings.
Interview Questions on Python String Formatting
These come from real screens and onsites. Practice answering before you read each answer.
Q: Python has four ways to format strings. Which one do you reach for by default, and why do the other three still exist?
f-strings are the default: they are the fastest, the most readable, and they accept any Python expression inside the braces. The other three survive for specific jobs. .format() handles templates that live in a variable or a config file, string.Template is the safe choice when the template text comes from a user, and the % operator lives on in legacy code and the logging module. A good answer names the default and can justify each exception in one line.
Q: You added debug logs like logging.debug(f"Processing {expensive_summary(order)}") and production, which runs at INFO level, got noticeably slower. What is going on and how do you fix it?
The f-string is evaluated the moment the line runs, so expensive_summary(order) executes on every call even though the DEBUG message is never emitted. That is exactly why the logging module keeps %-style placeholders: logging.debug("Processing %s", order) defers the formatting until the record is actually emitted. Careful with a follow-up trap here: arguments are still evaluated eagerly, so if the expensive part is a function call, guard it with if logger.isEnabledFor(logging.DEBUG):. The rule of thumb: f-strings everywhere, except inside logging calls in hot paths.
Q: Your web app lets customers customise their invoice text, and one customer saved the template {user.__class__.__init__.__globals__}. What is happening and what is the fix?
That is a format-string injection attack. If your code runs user_template.format(user=user), the placeholder walks attribute chains on the objects you pass in and can dump module globals, which often include secrets like Application Programming Interface (API) keys. The fix is to never feed untrusted text to .format(): switch to string.Template with safe_substitute(), which only understands flat $name placeholders and cannot access attributes or run expressions.
Q: How would you print the number 2500000 as 2,500,000.00 using an f-string?
Use a format specifier after the colon: f"{2500000:,.2f}". The comma adds thousands separators and .2f fixes two decimal places. The same specifier works unchanged in .format(), because both follow Python's Format Specification Mini-Language. Interviewers often follow up with alignment, so know :<10, :>10, and :^10 for left, right, and centre alignment in a 10-character column.
Q: What is the difference between Template.substitute() and Template.safe_substitute()?
substitute() raises a KeyError if any placeholder in the template has no matching value, which is what you want when a missing field is a real bug. safe_substitute() leaves unmatched placeholders in the output as literal text like $city and never raises. Pick safe_substitute() for user-facing templates with optional fields, and substitute() when silence would hide a mistake.
Q: What does f"{total=}" print, and when would you use it?
The = sign (added in Python 3.8) makes the f-string self-documenting: if total is 42, f"{total=}" produces the string total=42, printing both the expression and its value. It works with full expressions too, like f"{len(items)=}". It is a quick debugging tool that replaces the classic print("total:", total) pattern with less typing and zero chance of the label drifting out of sync with the variable.
Related Posts
Previous: Python: String Operations, The Complete Method Reference
Next: Python: Taking User Input with input(), Type Casting, Validation
Series Home: Python + AI/ML Tutorial Series

No comment