Type something to search...
f-Strings in Python: Formatting Tricks You Probably Aren't Using

f-Strings in Python: Formatting Tricks You Probably Aren't Using

Most Python developers use f-strings every day, but usually only for the simplest job: dropping a variable into a string. f"Hello, {name}" is nice, but it's a fraction of what f-strings can do. The same syntax handles number formatting, padding and alignment, dates, debugging output, and custom formatting for your own classes, all without calling a single extra function.

This post is a tour of the parts people skip. I'll cover the = debugging specifier, conversion flags, the format specification mini-language for numbers and alignment, dynamic (nested) format specs, dates, custom __format__ methods, and the syntax that became legal in Python 3.12. At the end there's a short look at template strings, which arrive in Python 3.14.

All examples run on Python 3.13.

A Quick Refresher on the Anatomy

An f-string replacement field has up to three parts after the expression:

{expression=!conversion:format_spec}
  • expression: any Python expression.
  • = (optional): print the expression text along with its value.
  • !conversion (optional): !r, !s, or !a.
  • :format_spec (optional): how to format the value, such as .2f or >10.

Each part is independent, so you can mix them. The rest of this post is mostly about what you can do with each one.

Debugging with =

Adding = after an expression prints the expression itself, then its value. It's the fastest way to inspect variables without writing the name twice:

user = "ada"
count = 3

print(f"{user=}")
print(f"{count * 2 = }")
user='ada'
count * 2 = 6

Two details make this more useful than it first looks:

  • Whitespace is preserved. {count * 2 = } prints count * 2 = 6, spaces included, so you can make the output as readable as you like.
  • It uses repr() by default. That's why the string shows up as 'ada' with quotes, which helps you spot stray whitespace or the difference between "3" and 3.

You can combine = with a conversion or a format spec. Adding a format spec switches from repr() to the formatted value:

from datetime import datetime

now = datetime(2026, 9, 14, 6, 5, 0)

print(f"{user = !s}")
print(f"{now=:%H:%M}")
user = ada
now=06:05

When you're chasing a bug, print(f"{response.status_code=} {len(items)=}") gives you labeled output in one line. For anything you keep, move to proper logging or a debugger; How to Debug a Python Program covers the tools.

Conversion Flags: !r, !s, !a

Conversions run before formatting:

  • !s calls str() (the default for most values).
  • !r calls repr().
  • !a calls ascii(), which escapes non-ASCII characters.
print(f"{user!r} {user!s} {'café'!a}")
'ada' ada 'caf\xe9'

!r is the one you'll use most. In error messages, f"Unknown option {name!r}" makes empty strings and whitespace visible: you'll see Unknown option '' instead of a confusing Unknown option.

Formatting Numbers

After the colon comes the format specification mini-language, the same one used by format() and str.format(). It's documented in full in the Python docs, but a handful of patterns cover almost everything.

Decimal Places, Thousands Separators, and Percentages

total = 1234567.891

print(f"{total:,.2f}")
print(f"{total:_.2f}")
print(f"{1234567:,}")
print(f"{0.4567:.1%}")
1,234,567.89
1_234_567.89
1,234,567
45.7%
  • .2f means fixed-point with two decimals.
  • , or _ before the precision adds a thousands separator.
  • % multiplies by 100 and adds a percent sign, so you don't have to do the math yourself.

Scientific and General Notation

print(f"{1234567.891:.3e}")
print(f"{1234567.891:.3g}")
1.235e+06
1.23e+06

e always uses scientific notation. g ("general") picks fixed or scientific depending on magnitude, and treats the precision as significant digits rather than decimal places.

Signs and Zero Padding

print(f"{-3:+d} {3:+d} {3: d}")
print(f"{7:03d}")
print(f"{42:08.3f}")
-3 +3  3
007
0042.000
  • + always shows a sign; a space shows a space for positive numbers so columns line up with negatives.
  • A leading 0 before the width pads with zeros. 03d is the classic way to get 007. The width counts every character, including the decimal point and decimals, which is why 08.3f gives four leading characters before the point.

Binary, Octal, and Hex

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

# adds the 0b, 0o, or 0x prefix. #010b pads to a total width of 10 including the prefix, which is handy when printing bit flags.

A Note on Rounding

Formatting with .0f uses round-half-to-even ("banker's rounding"), the same as the built-in round(), and it operates on the binary floating-point value:

print(f"{3.0:.0f} {2.5:.0f} {3.5:.0f}")
print(f"{0.1 + 0.2}")
print(f"{0.1 + 0.2:.2f}")
3 2 4
0.30000000000000004
0.30

2.5 rounds down to 2 and 3.5 rounds up to 4. If you're formatting money, store the amounts as Decimal or integer cents rather than floats. Decimal supports the same format specs:

from decimal import Decimal

print(f"{Decimal('19.999'):.2f}")
20.00

Alignment and Padding

Width and alignment let you build tidy columns without any library:

print(f"[{'left':<10}] [{'right':>10}] [{'mid':^10}] [{'mid':*^10}]")
print(f"[{42:>6}] [{'x':>6}]")
[left      ] [     right] [   mid    ] [***mid****]
[    42] [     x]
  • <, >, and ^ mean left, right, and center.
  • A character before the alignment symbol is the fill character (*^10).
  • Strings align left by default and numbers align right, so {42:6} and {42:>6} produce the same thing.

Combine width with number formatting to build a simple table:

rows = [("mug", 12, 2), ("lamp", 65.5, 1)]

for name, price, qty in rows:
    print(f"{name:<8}{price:>8.2f}{qty:>5}")
mug        12.00    2
lamp       65.50    1

You can also stack alignment, width, separator, and precision in one spec:

print(f"{1234.5:>12,.2f}|")
    1,234.50|

Truncating Strings

For strings, the precision field truncates:

print(f"{'abc':.2}")
ab

Combine it with a width ({title:20.20}) to force a value into exactly 20 characters, padded or cut as needed.

Nested Format Specs

The format spec itself can contain replacement fields. That lets you choose width, precision, or alignment at runtime:

import math

width, prec = 10, 3
align = "^"

print(f"[{3.14159:{width}.{prec}f}]")
print(f"[{'hi':{align}{width}}]")
print(f"{math.pi:.{prec}f}")
[     3.142]
[    hi    ]
3.142

A practical use: computing the column width from the data so a report always fits its longest entry.

names = ["mug", "desk lamp", "pen"]
width = max(len(n) for n in names)

for n in names:
    print(f"{n:<{width}} |")
mug       |
desk lamp |
pen       |

Formatting Dates and Times

datetime, date, and time objects implement __format__ by passing the spec to strftime(). That means you can put strftime codes straight into the f-string:

from datetime import date, datetime

now = datetime(2026, 9, 14, 6, 5, 0)

print(f"{now:%Y-%m-%d %H:%M}")
print(f"{now:%A, %B %d}")
print(f"{date(2026, 9, 14):%d/%m/%Y}")
2026-09-14 06:05
Monday, September 14
14/09/2026

No .strftime() call needed. Day and month names come from the current locale, so they'll be English unless you've changed it. For time zones and arithmetic, see Working with Dates and Times in Python.

Custom Formatting with __format__

The format spec isn't hardcoded into f-strings. Python calls format(value, spec), which calls the object's __format__ method. Your own classes can define it and accept whatever spec syntax makes sense:

class Money:
    def __init__(self, cents: int) -> None:
        self.cents = cents

    def __format__(self, spec: str) -> str:
        if spec == "short":
            return f"${self.cents // 100}"
        return f"${self.cents / 100:{spec or '.2f'}}"

    def __repr__(self) -> str:
        return f"Money({self.cents})"


m = Money(1999)
print(f"{m} {m:short} {m:.1f} {m!r}")
$19.99 $19 $20.0 Money(1999)

Here {m} calls __format__ with an empty spec, {m:short} hits the custom branch, {m:.1f} forwards a numeric spec, and {m!r} bypasses __format__ entirely by converting with repr() first. This is a clean way to give domain objects a few named display formats without adding a method for each.

Literal Braces

To include a literal { or }, double it:

count = 3

print(f"{{literal braces}} {count}")
print(f"{{{count}}}")
{literal braces} 3
{3}

The second line needs three braces on each side: two for the literal brace and one for the replacement field. This comes up when generating JSON-like text or CSS, although for JSON you should use json.dumps() rather than building strings by hand.

What Python 3.12 Made Legal

PEP 701, shipped in Python 3.12, formalized f-strings in the grammar and removed several old restrictions. If you learned f-strings earlier, some of these used to be syntax errors.

Reusing the Same Quotes

You can now use the same quote character inside the expression as around the string:

items = {"name": "mug", "price": 12}

print(f"{items["name"]}")
mug

Before 3.12 you had to write items['name'] with the other quote style.

Backslashes in Expressions

Expressions can contain backslashes, so joining with a newline works inline:

names = ["a", "b"]

print(f"{"\n".join(names)}")
a
b

Multi-line Expressions and Comments

Replacement fields can span lines and include comments:

user = "ada"

print(f"{
    user.upper()  # comments are allowed here now
}")
ADA

You probably won't write this often, but it's useful for long expressions in generated reports. If you need to support Python 3.11 or older, stick to the old rules.

Things to Keep Out of f-Strings

f-strings evaluate immediately, which makes them the wrong tool in a few places:

  • Logging calls. logger.debug(f"payload={payload}") builds the string even when debug logging is off. Use logger.debug("payload=%s", payload) so formatting only happens if the message is emitted. Logging in Python goes into more detail.
  • SQL and shell commands. Interpolating user input into SQL or a shell string opens you up to injection. Use query parameters and argument lists instead.
  • User-supplied templates. If the template comes from configuration or users, use str.format() with known fields or string.Template. f-strings only work on literals in your source code, which is a feature: they can't be used to evaluate arbitrary text.
  • Heavy logic. f"{(lambda x: x * 2)(4)}" works, but if an expression needs a lambda or a nested comprehension, compute it into a variable first. Readability wins.

A Look Ahead: Template Strings in Python 3.14

Python 3.14 adds template strings (PEP 750), written with a t prefix instead of f. The syntax inside is the same, but instead of producing a str, a t-string produces a string.templatelib.Template object that keeps the static text and the interpolated values separate:

# Python 3.14+
name = "ada"
template = t"Hello, {name}!"
print(type(template))  # <class 'string.templatelib.Template'>

Nothing is combined until some code processes the template. That's the point: a library can receive a template and escape HTML, parameterize SQL, or build structured log records from the interpolated values safely, while you keep writing familiar f-string-like syntax. For plain string building, f-strings remain the right choice.

Quick Reference

GoalSpecExample output
Debug a variable{x=}x=42
Two decimals{x:.2f}3.14
Thousands separator{x:,}1,234,567
Percentage{x:.1%}45.7%
Zero-padded int{x:03d}007
Hex with prefix{x:#x}0xff
Right-align in 10{x:>10}right
Center with fill{x:*^10}***mid****
Truncate{x:.2}ab
Dynamic width{x:{w}}
Date{d:%Y-%m-%d}2026-09-14
repr{x!r}'ada'

Conclusion

f-strings are more than string concatenation with nicer syntax. The = specifier gives you labeled debug output for free, the format spec mini-language handles decimals, separators, percentages, padding, and alignment, nested specs let you decide those at runtime, and __format__ lets your own classes join in. Python 3.12 removed the old quoting and backslash restrictions, and 3.14's template strings build on the same syntax for safe, deferred processing.

Pick two or three of these, like {x=}, {x:,.2f}, and {name:<{width}}, and start using them. They'll replace a surprising number of helper functions and manual round() calls in your code.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading