Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. f-strings in Python: formatting, specs, and the = trick
Learn

f-strings in Python: formatting, specs, and the = trick

An f prefix lets you put expressions straight into a string. The format specs and the debugging form are what make them worth learning properly.

By Max Arthur
Co-Founder & Content Marketer·September 12, 2026·8 Min read
White text reading 'Python f-strings explained' on a dark purple dotted background

An f-string is a string with an f before the opening quote. Anything inside {} is evaluated and inserted:

name = "Ada"
age = 36
print(f"{name} is {age}")
# Ada is 36

The braces take expressions, not just variable names:

print(f"{age * 2}")
# 72

That is the whole basic idea. What makes f-strings worth learning past that point is the format specification after a colon, and one debugging shortcut that saves a surprising amount of time.

Every output on this page came from Python 3.13 through Pyodide 0.28.3.

Format specs

After a colon inside the braces, you can say how the value should be rendered:

pi = 3.14159
print(f"{pi:.2f}")        # 3.14
print(f"{1234567:,}")     # 1,234,567
print(f"{0.256:.1%}")     # 25.6%
print(f"{42:05d}")        # 00042
Spec Does Example output
.2f Fixed decimal places 3.14
, Thousands separator 1,234,567
.1% Percentage, one decimal 25.6%
05d Pad with zeros to width 5 00042
.3e Scientific notation 3.142e+00

.2f is the one you will use most. It rounds for display without changing the underlying number, which is exactly what you want for money and measurements.

Alignment

Three characters control alignment, with a width after them:

print(f"{'left':<10}|{'right':>10}|{'mid':^10}|")
# left      |     right|   mid    |

< left, > right, ^ centre. This is how you produce aligned columns in terminal output without a table library. Add a fill character before the alignment marker to pad with something other than a space: {'x':*^9} centres inside asterisks.

The = trick

This is the feature most worth knowing and the one most often missed:

print(f"{name=}")
# name='Ada'

Adding = prints both the expression and its value. It works on any expression, not just names:

print(f"{age * 2 = }")
# age * 2 = 72

Spaces around the = are preserved in the output, so you can control the spacing.

This replaces the most common debugging line in Python. Instead of:

print("total_price:", total_price)

you write print(f"{total_price=}") and the label can never drift out of sync with the value, because there is only one place the name is written. On a line logging four variables, that matters.

Nesting values inside the spec

The format spec can itself contain braces, so widths and precisions can be computed:

w = 8
print(f"{pi:{w}.3f}")
#    3.142

Useful when the column width comes from the data, such as the longest name in a list.

Literal braces, and quotes

Two braces produce one:

print(f"{{literal braces}}")
# {literal braces}

Quotes inside the expression used to be a problem. Before Python 3.12 you could not reuse the same quote character that delimited the string, so this was a syntax error and people worked around it with alternating quote styles:

print(f"{items['key']}")   # fine: different quote characters

Python 3.12 lifted the restriction, so the same quotes now nest. If you support older versions, keep alternating them.

Conversions and format hooks

!r gives you the repr rather than the string form:

print(f"{name!r}")
# 'Ada'

The quotes are visible, which is exactly what you want in debugging output, because it distinguishes Ada from "Ada " with a trailing space. !s forces str and !a forces ASCII.

Objects can define their own format specs. Dates are the everyday example:

from datetime import date
print(f"{date(2026, 9, 8):%Y-%m-%d}")
# 2026-09-08

The spec after the colon is passed to the date's own formatter, so any strftime pattern works there.

Conditionals inside braces

Any expression is allowed, including a conditional:

print(f"{'yes' if age > 18 else 'no'}")
# yes

This is legal and worth using sparingly. A short ternary reads fine; anything longer belongs on its own line above, because a complicated expression buried in a string is hard to read and harder to debug.

Rounding is not what you expect

Worth knowing before you format money:

print(f"{3.14159:.0f} {2.5:.0f} {3.5:.0f}")
# 3 2 4

2.5 became 2 and 3.5 became 4. Python rounds half values to the nearest even number, not always upward. This is round-half-to-even, and it is deliberate: always rounding up biases a long series of numbers slightly high, while rounding to even cancels out.

It surprises everyone the first time, and it means display rounding on financial figures can disagree with a spreadsheet. If you need the round-half-up behaviour, format through decimal with an explicit rounding mode rather than fighting the f-string.

Multiline and tables

Triple quotes work with the f prefix, and line breaks are preserved:

print(f"""multi
line {name}""")

Combining alignment with number formatting is how you produce a readable table:

print(f"{'Name':<10}{'Total':>10}")
print(f"{name:<10}{total:>10,.2f}")
Name           Total
Ada         1,234.50

The spec >10,.2f stacks three things: right-align in ten characters, thousands separators, two decimals. They combine in that order, and reading them right to left (f float, .2 precision, , grouping, 10 width, > alignment) makes them easier to construct.

Number bases

Less common but occasionally exactly what you need:

n = 255
print(f"{n:b} {n:o} {n:x} {n:X} {n:#x}")
# 11111111 377 ff FF 0xff

Binary, octal, lowercase hex, uppercase hex, and the # prefix form that includes 0x. Useful for colours, permissions and byte-level work, and it saves a call to hex() plus string slicing.

When f-strings are the wrong tool

Two cases, and both matter.

Logging. Use the logging module's own placeholders instead:

logging.info("User %s failed login", user)     # correct
logging.info(f"User {user} failed login")      # avoid

The f-string builds the message immediately, even when the log level means it will be discarded. The %s form is only formatted if the message is actually emitted.

SQL queries. Never build a query with an f-string:

cur.execute(f"SELECT * FROM users WHERE name = '{name}'")   # unsafe
cur.execute("SELECT * FROM users WHERE name = ?", (name,))  # correct

The first inserts raw text into the query, which is SQL injection. The second passes the value separately so the database treats it as data. This is the one place where the convenience of f-strings is actively dangerous.

The older forms

You will meet two other styles in existing code:

"%s is %d" % (name, age)        # oldest
"{} is {}".format(name, age)    # str.format
f"{name} is {age}"              # f-string

All three work. f-strings are the current recommendation because the values sit where they are used rather than in a list at the end, which is easier to read and harder to misalign. .format() still has a use when the template and the values are genuinely separate, such as a message string loaded from a file, since an f-string is evaluated where it is written.

Three common mistakes

Forgetting the f. A plain string with braces prints the braces:

print("{name} is here")     # {name} is here

No error, because that is a perfectly valid string. If your output contains literal braces and names, this is why. Editors with syntax highlighting colour the interpolated parts, which makes the missing prefix visible at a glance.

Putting a backslash inside the expression. Before Python 3.12 this was a syntax error:

f"{'\n'.join(items)}"       # error on older versions

The fix that works everywhere is to compute it first:

joined = "\n".join(items)
print(f"{joined}")

That reads better anyway, and it is the general answer whenever an expression inside braces starts getting long.

Formatting a value that might be None. f"{value:.2f}" raises TypeError when value is None, and it does so at the point of display rather than where the None came from. Guard it or supply a default before formatting.

Recipes worth keeping

The specs combine, and a handful of combinations cover most real output:

f"${amount:,.2f}"          # $1,234.50   money
f"{ratio:.1%}"             # 25.6%       percentage
f"{n:>8}"                  # right-aligned in a column
f"{name:<20}{count:>5}"    # a two-column table row
f"{value:.3g}"             # 3 significant figures
f"{when:%d %b %Y}"         # 08 Sep 2026
f"{path!r}"                # quoted, shows hidden whitespace

Two that come up constantly and are not obvious:

Plurals need a conditional rather than a spec:

f"{n} item{'s' if n != 1 else ''}"

Truncating long text uses precision on a string, which is easy to forget works:

f"{title:.20}"     # first 20 characters
f"{title:<20.20}"  # exactly 20 wide, padded or truncated

That second one is how you keep a column from breaking when a value is longer than expected.

Are they fast

Yes, and measurably faster than the alternatives, because the formatting is compiled into the bytecode rather than interpreted from a template string at runtime. % formatting and str.format both have to parse their template each time they run.

That is a reason to prefer f-strings, not a reason to rewrite working code. The difference is meaningful in a tight loop producing millions of strings, and invisible anywhere else.

The one place the speed argument reverses is logging, covered below: there, the fastest thing is not formatting at all.

Quick reference

f"{name}"            # value
f"{a + b}"           # expression
f"{pi:.2f}"          # 2 decimal places
f"{n:,}"             # thousands separator
f"{r:.1%}"           # percentage
f"{n:05d}"           # zero-padded
f"{s:<10}"           # left align in width 10
f"{name=}"           # name='Ada'  (debugging)
f"{name!r}"          # repr
f"{d:%Y-%m-%d}"      # date formatting
f"{{}}"              # literal braces

Ready to format real output? Start with the Python track, or read Python for loops for the loops these lines usually sit inside.

Keep reading

More from the blog

White text reading 'How range() works in Python' on a dark purple dotted background
October 12, 2026·8 min readLearn

range() in Python: start, stop, step, and why it is lazy

range(1000000) uses 24 bytes. A list of a thousand numbers uses 4,028. Here is what range actually is, measured.

Read more
White text reading 'How to convert a string to a number in JavaScript' on a dark purple dotted background
October 3, 2026·9 min readLearn

How to convert a string to a number in JavaScript

Converting a string to a number in JavaScript: an empty string is 0 with Number and NaN with parseInt. What four methods do to awkward input.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentReact DeveloperPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs Mimo
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs Sololearn
Devpuff vs freeCodeCamp
Read All Comparisons