AlgoMaster Logo

String Formatting (f-strings, format, %)

High Priority26 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

Building strings out of values is something every program does, from printing a cart total to writing a log line. Python gives you three ways to do it: f-strings (the modern default), str.format() (the older keyword-friendly form), and %-formatting (a holdover from C). This lesson covers all three, the format-spec mini-language they share, and when each one earns its place in real code.

Why a Formatting System Exists at All

The naive way to build a string is to glue pieces together with +:

That works, but it's painful. Every non-string value needs an explicit str() call. Spaces are easy to forget. The $ sits awkwardly next to a +. And there's no control over how the number prints. price shows as 29.99 here only because the literal happened to have two decimals. If the price were 30, you'd get $30, not $30.00.

Formatting fixes both problems: it converts values for you, and it lets you say _how_ each value should look. The same line in f-string form:

One readable line, no str() calls, prices forced to two decimals. That's the payoff. The next sections walk through how each style spells the same idea, starting with the one you'll write 95% of the time.

f-strings (the Modern Default)

An f-string is a string literal prefixed with f (or F). Anything inside {...} is evaluated as a Python expression and dropped into the result:

f-strings landed in Python 3.6 and changed how Python code looks. They're faster than .format() because the compiler turns each {...} directly into a load-and-format instruction at parse time, with no runtime template lookup. They're also easier to read, since the values appear right where they belong in the sentence.

Expressions, Not Just Names

The thing inside the braces doesn't have to be a variable. It can be any Python expression:

You can call methods, index into containers, do arithmetic, and even use conditional expressions. The only things you can't put inside the braces (in older versions) were backslashes and the same kind of quote that wraps the string. Python 3.12 lifted that restriction, but it's still cleaner to keep complex logic out of the braces and assign it to a variable first.

The = Debug Form

Added in Python 3.8, the = flag inside an f-string prints both the expression and its value. It's built for quick debugging:

The exact text inside the braces, including spaces, is what gets echoed. f"{ x = }" prints ' x = 4' with the spaces preserved. This is much nicer than scattering print("cart_total:", cart_total) lines through your code while hunting a bug.

Conversion Flags: !r, !s, !a

Before applying any format spec, you can convert the value through one of three functions:

FlagCallsUse for
!sstr(value)Default, rarely written explicitly
!rrepr(value)Debugging, shows quotes around strings
!aascii(value)Debugging non-ASCII, escapes Unicode

!r is the one you'll actually use. When a log line says Order id was order_123, you can't tell if the value is a string "order_123" or some other type that prints the same. !r makes that ambiguity disappear by adding the quotes, brackets, and other markers that repr() produces.

Brace Escaping

Since { and } mean "expression here", you need a way to write literal braces. Double them:

{{ becomes a single { in the output, and }} becomes }. The escaping happens before the brace-content is parsed, so {{customer_name}} is just the literal text, never an expression.

Multiline f-strings

f-strings work with triple-quoted strings, which is handy for building receipts, emails, or any block of text with multiple values dropped in:

Triple-quoted f-strings preserve every newline and space inside the quotes. The blank line at the top and bottom of the output comes from the newlines right after """ and right before the closing """. If you don't want them, put the opening """ on the same line as the first text and avoid the trailing newline.

The Format Spec Mini-Language

Both f-strings and .format() share the same grammar for what goes after the : inside a placeholder. This is the format spec mini-language. The full grammar is:

Every field is optional, but the order is fixed. Here's what each one controls:

FieldValuesEffect
fillAny characterCharacter used for padding (requires align)
align< > ^ =Left, right, center, sign-aware (numbers only)
sign+ - Show sign always, only on negatives, or space for positive
#flagAlternate form (adds 0b, 0o, 0x prefixes for ints)
0flagZero-pad numbers (shortcut for 0=)
widthintegerMinimum total width of the output
grouping, or _Thousands separator
.precisionintegerDecimal places for floats, max length for strings
typeletterHow to interpret the value (see table below)

Here's the field order visualized:

You almost never use all of them at once. Most format specs are two or three fields: width plus type, or precision plus type, or just a type code on its own.

Width and Alignment

Width sets a minimum number of characters. Alignment says where to put the value when the output is shorter than the width:

Strings default to left-aligned. Numbers default to right-aligned, which is why money columns naturally line up. The pipes are there to make the width visible.

Fill Character

Fill is the character used to pad the value out to the width. It always goes immediately before the alignment marker:

*^30 means "fill with *, center, width 30". 0>5 means "fill with 0, right-align, width 5". The fill defaults to a space when you don't specify one, which is why all the earlier examples padded with spaces.

The 0 flag (immediately after sign, before width) is a shortcut for 0=, which is sign-aware zero padding for numbers:

With :08d, the - stays on the left and zeros fill between the sign and the digits. With :>08d, the right-align is explicit and you get the same result. The only difference shows up with negative numbers: :08d keeps the sign at the very left, while :>08 would shove it inward.

Type Codes

The type code controls what kind of value Python is formatting and how to render it. Pick the wrong code for the type and you get an error.

CodeForWhat it does
sstrPlain string (default for strings)
dintDecimal integer
bintBinary
ointOctal
x / XintHex (lowercase / uppercase)
cintCharacter (Unicode codepoint)
nint / floatLocale-aware number
f / FfloatFixed-point decimal
e / EfloatScientific notation
g / GfloatGeneral (chooses f or e)
%floatPercentage (multiplies by 100, adds %)

A few examples:

The % type is convenient: it multiplies the value by 100 and adds the % sign in one step. So 0.075 becomes 7.5%. The , grouping turns 12345.68 into 12,345.68. These small touches stack up into receipts and reports that read like a person made them.

Precision

For floats, .precision is the number of digits after the decimal point. For strings, it's the maximum number of characters to keep:

Float precision rounds (banker's rounding, which rounds halves toward even). String precision truncates without warning. Combine width and precision to get a fixed-width string that won't blow out a column:

Width 15, precision 15, left-aligned. Short names get padded, long names get cut off. The result is a tidy column that lines up regardless of input length.

Grouping with , and _

For numbers, , adds thousands separators using commas, and _ uses underscores. Both work for ints and floats:

The grouping character goes right before the precision. Underscores show up most often when grouping binary or hex output, where commas would be misleading:

For binary and hex, the grouping is every 4 digits. For decimal, it's every 3 digits. That difference is built into the language and not configurable.

Sign Control

The sign field decides what shows up in front of a number:

SignBehavior
-Sign on negatives only (default)
+Sign on both positive and negative
(space)Space for positive, - for negative

The + form is useful for change reports where you want +250 to look different from 250. The space form is for tabular output where you want positives and negatives to share an alignment column.

The # Alternate Form

The # flag adds the type prefix for binary, octal, and hex output:

Without #, you get the digits only. With #, you get the prefix that Python uses for literals. Useful when you want the output to be valid Python syntax, like in generated code or REPL-style debugging.

Nested Format Specs

A format spec field can itself contain a placeholder. This is how you parameterize width and precision at runtime:

The braces inside the format spec aren't escaped, they're full placeholders. They get evaluated first, then their values are spliced into the spec, then the outer formatting runs. You can nest as deep as you want, but two levels is usually the most you need.

This is also the trick for column tables where the column widths come from data, not literals. You compute the widest entry, then format every row with that width.

str.format()

Before f-strings, the recommended way to format was str.format(). It's still around, still works, and you'll see it in older codebases and in cases where the template lives apart from the values (like a config file or a translation file).

The basic syntax replaces {...} placeholders with positional or keyword arguments:

Empty braces {} consume positional arguments left to right. The format spec after : works exactly the same way as in f-strings.

Indexed Placeholders

Numbered placeholders let you reference positional arguments by index, including reusing the same one:

Numbering is handy when you want to emphasize which argument goes where, especially in templates with many fields.

Named Placeholders

Named placeholders pull from keyword arguments. This is the form you'll see in templates that come from configuration:

Named placeholders are self-documenting. A reader can see what each value means without looking at the call site.

Attribute and Item Access

Placeholders can dig into the structure of an argument using . (attribute) and [] (item) access:

Inside a format placeholder, [0] doesn't need quotes for integer indices, but it _does_ need to be a literal (you can't say [i] to use a variable). For string keys, you write {d[name]} without quotes, which looks odd but is the rule.

Same Format Spec, Different Engine

Everything from the format-spec mini-language applies inside .format() too:

The same spec strings work whether you write them in an f-string or pass them to .format(). That's the value of having one shared mini-language.

%-style Formatting (the Legacy Form)

The oldest formatting style in Python uses % as the placeholder marker, borrowed straight from C's printf. You'll still see it in older code and inside the standard logging module, where it's the default for performance reasons.

The basic form takes a template string with %-prefixed type codes and applies a tuple of values:

Each % in the template matches one entry in the tuple, in order. The type codes are mostly the same as the format-spec mini-language: %s for strings, %d for integers, %f for floats, %x for hex, %e for scientific.

%r and %%

Two extras to know: %r calls repr() (the equivalent of !r in f-strings), and %% is how you write a literal percent sign:

%r is the same trick as !r: it makes ambiguous values self-documenting in debug output. %% is necessary because %d, %s, etc. would otherwise be parsed as a format code.

Single Value Without a Tuple

When there's exactly one value, you don't need the tuple, you can pass the bare value:

This is convenient but it's also a footgun. If your "single value" happens to be a tuple, Python tries to expand it into multiple slots. Always wrap in a tuple when you're not sure: "%s" % (value,) is safe, "%s" % value works only if value isn't itself a tuple.

Mapping Form

Instead of a tuple, you can pass a dict and use named placeholders:

The names go in parentheses right after the %, before the type code. This is rare in modern code, but it's still in the docs and in the logging module's configuration formats.

Where You'll Still See It

Three places %-formatting still shows up:

  1. The `logging` module: log.info("Customer %s placed order %d", name, order_id) is the standard form. Logging uses lazy %-formatting so the string only gets built when the message actually gets emitted.
  2. Old codebases: Anything written before Python 3.6 likely uses % or .format(). New code should use f-strings unless there's a reason not to.
  3. Tight, single-token templates: "$%.2f" % price is shorter than f"${price:.2f}" by a few characters, though most people still prefer the f-string for consistency.

Comparing the Three Styles

Here's the same line written three ways, then a side-by-side rundown:

All three produce the same string. They differ in syntax, performance, and how well they handle edge cases:

Featuref-string.format()%-style
Python version3.6+2.6+All versions
SpeedFastestSlow (parses template each call)Fast
Inline expressionsYes ({a + b})NoNo
Self-documenting ({x=})Yes (3.8+)NoNo
Template can be stored in a variableNoYesYes
Reads like a sentenceYesMostlyMostly
Works with gettext/logging lazy evalNoNoYes (logging)
Brace escape{{ }}{{ }}%%
Conversion flags!r !s !a!r !s !a%r %s

A simple decision rule:

  • Default to f-strings. They're faster, more readable, and let you put expressions right where you need them.
  • Use `.format()` when the template needs to live in a config file, translation file, or any variable where the values aren't available yet.
  • Use `%`-formatting for logging calls where lazy evaluation matters. Don't use it for general string building in new code.

Putting It All Together: a Receipt

Here's a small program that prints a receipt using width, alignment, precision, and grouping all at once. This is the kind of place where the format-spec mini-language earns its keep:

Three things to notice. The column widths come from variables (NAME_W, QTY_W, etc.) that the format spec references through nested placeholders. Names get truncated to the column width with .{NAME_W}, so a long product name can't blow out the layout. Prices are right-aligned, comma-grouped, and pinned to two decimals. Change a width constant at the top, and every column adjusts.

Quiz

String Formatting Quiz

10 quizzes