f-strings and String Formatting in Python

You've already seen python f-strings scattered throughout earlier articles in this series — they're simply the cleanest way to combine text and values in Python. This article goes deeper: the full formatting mini-language behind that {value:...} syntax, what came before f-strings, and a few patterns worth knowing once you're comfortable with the basics.

What Are f-strings?

An f-string is a string literal prefixed with f (or F) that lets you embed expressions directly inside curly braces {}. Introduced in Python 3.6 through PEP 498, they've become the modern standard for python string formatting.

name = "Alex"
age = 30

print(f"{name} is {age} years old")
# Alex is 30 years old

Whatever's inside the braces gets evaluated and inserted directly into the string at that position — no separate formatting call, no placeholder tracking.

Why f-strings Over Older Methods

Python has actually had three distinct eras of string formatting, and it's worth knowing all three, since you'll still encounter the older ones in existing code.

The three eras
name = "Alex"

# Era 1: the % operator (oldest)
print("%s is here" % name)

# Era 2: the .format() method
print("{} is here".format(name))

# Era 3: f-strings (current standard)
print(f"{name} is here")
Why f-strings win

f-strings are generally preferred now for a few concrete reasons:

  • Readability — the value sits directly where it's used, instead of being tracked separately at the end of the string (as with %) or referenced by position/name elsewhere (as with .format()).

  • Performance — f-strings are evaluated at compile time in a way that's typically faster than the alternatives, since Python doesn't need to parse a separate format string and match up arguments at runtime.

  • Conciseness — no repeated placeholders, no .format() call to close out, no separate %-style conversion characters to remember.

Where the older styles still show up

You'll still run into % formatting and .format() in legacy codebases, certain logging libraries (Python's built-in logging module still commonly uses %-style formatting internally for performance reasons), and in code written before Python 3.6. Recognizing them is useful even if you rarely write them yourself going forward.

The Format Specification Mini-Language

This is where f-strings go from "convenient" to genuinely powerful. Python string formatting supports a full mini-language, invoked with a colon inside the braces: {value:format_spec}.

Number formatting: decimal precision
price = 49.5
print(f"")   # $49.50 — rounds to 2 decimal places
Thousands separators
population = 1000000
print(f"{population:,}")     # 1,000,000 — comma separator
print(f"{population:_}")     # 1_000_000 — underscore separator
Padding and zero-fill
number = 7
print(f"{number:05}")   # 00007 — pads with zeros to a total width of 5

This is useful for things like consistently formatted IDs, timestamps, or file numbering, where you want a fixed visual width regardless of the actual value's length.

Alignment and width

You can control alignment within a fixed-width field using < (left), > (right), and ^ (center), optionally paired with a custom fill character:

print(f"{'left':<10}|")     # left      |  — left-aligned, padded to 10 chars
print(f"{'right':>10}|")    #      right|  — right-aligned, padded to 10 chars
print(f"{'mid':^10}|")      #    mid    |  — centered within 10 chars
print(f"{'pad':*^10}|")     # ***pad****|  — centered, with * as the fill character

This combination — width plus alignment plus a fill character — is exactly what you'd reach for when building simple aligned table output in the console.

Date and time formatting

f-strings can format datetime objects directly, using the same strftime codes you'd pass to .strftime():

from datetime import datetime

now = datetime(2026, 7, 9)
print(f"{now:%Y-%m-%d}")     # 2026-07-09
print(f"{now:%B %d, %Y}")    # July 09, 2026

No separate .strftime() call needed — the format spec after the colon handles it inline.

Beyond Simple Variables

Embedding expressions and function calls

The curly braces in an f-string aren't limited to plain variable names — any valid Python expression works, including function calls and arithmetic:

price = 49.5
tax_rate = 0.08

print(f"Total with tax: {price * (1 + tax_rate):.2f}")

def get_greeting():
    return "Hello"

print(f"{get_greeting()}, world!")
Conditional logic inline

You can even embed a conditional expression directly:

age = 15
print(f"You are {'an adult' if age >= 18 else 'a minor'}")

Used sparingly, this is a compact way to handle simple either/or text — but it's worth resisting the temptation to cram anything more complex than a one-line conditional into an f-string. If the logic needs more than that, compute it on a separate line first, then reference the resulting variable inside the f-string.

Accessing dictionary keys, object attributes, and list items

f-strings handle these all naturally, without any special syntax beyond what you'd normally write:

user = {"name": "Alex", "age": 30}
print(f"{user['name']} is {user['age']} years old")

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
print(f"Point at ({p.x}, {p.y})")

items = ["apple", "banana", "cherry"]
print(f"First item: {items[0]}")
The debug specifier (=)

Python 3.8 added a genuinely handy shortcut for debugging: appending = right before the closing brace prints both the expression's text and its value, without you having to type the variable name twice.

name = "Alex"
age = 30

print(f"{name=}")   # name='Alex'
print(f"{age=}")    # age=30
print(f"{age * 2=}")   # age * 2=60

This is a small but genuinely useful trick for quick debugging — dropping a print with {variable=} gives you both the label and the value in one line, instead of writing print(f"name: {name}") manually.

Conversion flags

You can also control exactly how a value is converted to a string using conversion flags — !s (str), !r (repr), and !a (ascii):

text = "café"

print(f"{text!s}")   # café — regular string conversion (the default)
print(f"{text!r}")   # 'café' — repr, includes quotes, useful for debugging
print(f"{text!a}")   # 'caf\xe9' — ascii-only representation, escaping non-ASCII characters

!r in particular is worth knowing — it's a common choice when logging or debugging, since it makes it visually obvious that a value is a string (surrounded by quotes) rather than some other type that happens to print similarly.

Practical Patterns and Best Practices

Real-world use cases

f-strings show up constantly in:

  • Log messages — combining a timestamp, log level, and message into one readable line.

  • Formatted reports and invoices — aligning columns, formatting currency, and applying consistent decimal precision.

  • Aligned table output — using width and alignment specifiers to keep console output readable across rows.

items = [("Apple", 1.50), ("Bread", 3.25), ("Milk", 2.10)]

for name, price in items:
    print(f"{name:<10}")
Nesting format specs dynamically

You can even make the width or precision itself a variable, by nesting another set of braces inside the format spec:

value = 3.14159
width = 10
precision = 2

print(f"{value:{width}.{precision}f}")
#     '      3.14' — width and precision both pulled from variables

This is useful when formatting needs to adapt based on some other calculated value — for example, aligning columns to the width of the longest entry in a dataset, determined at runtime rather than hardcoded.

Security note: never build f-strings from untrusted input

This is worth taking seriously: f-strings evaluate real Python expressions. If you ever find yourself constructing an f-string's template dynamically from user-supplied text — rather than just plugging user data into a fixed, hardcoded f-string you wrote yourself — you're opening the door to arbitrary code execution. Never do this:

# Dangerous — never do this
user_supplied_template = get_untrusted_input()
result = eval(f'f"{user_supplied_template}"')

The f-strings you write directly in your source code are completely safe — the risk only appears if you try to build the format string itself from untrusted input, rather than just inserting untrusted values into a template you control.

When to reach for string.Template instead

If you genuinely need to accept a format string from an untrusted source — a configuration file, user input, an external template — Python's built-in string.Template class is the safer tool for that specific job. It uses a much more restricted placeholder syntax ($name rather than arbitrary expressions), which means it can't be exploited to execute arbitrary code the way a dynamically constructed f-string could:

from string import Template

template = Template("Hello, $name!")
print(template.substitute(name="Alex"))   # Hello, Alex!

For everyday code where you're writing the format string yourself, f-strings remain the right default — string.Template is specifically for the narrower case of handling format strings that come from somewhere you don't fully trust.