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.
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.
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.
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.
Cost: f-strings are evaluated each time the code runs. A long f-string in a hot loop allocates a new string object every iteration. If you're logging at debug level inside a tight loop, build the string only when needed: if log.isEnabledFor(DEBUG): log.debug(f"...").
= Debug FormAdded 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.
!r, !s, !aBefore applying any format spec, you can convert the value through one of three functions:
| Flag | Calls | Use for |
|---|---|---|
!s | str(value) | Default, rarely written explicitly |
!r | repr(value) | Debugging, shows quotes around strings |
!a | ascii(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.
Cost: !r calls repr(), which can be expensive on large containers. f"{huge_dict!r}" walks every key and value to build the debug string, even if the log line never gets emitted. Guard expensive debug formatting behind a level check.
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.
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.
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:
| Field | Values | Effect |
|---|---|---|
fill | Any character | Character used for padding (requires align) |
align | < > ^ = | Left, right, center, sign-aware (numbers only) |
sign | + - | Show sign always, only on negatives, or space for positive |
# | flag | Alternate form (adds 0b, 0o, 0x prefixes for ints) |
0 | flag | Zero-pad numbers (shortcut for 0=) |
width | integer | Minimum total width of the output |
grouping | , or _ | Thousands separator |
.precision | integer | Decimal places for floats, max length for strings |
type | letter | How 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 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 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.
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.
| Code | For | What it does |
|---|---|---|
s | str | Plain string (default for strings) |
d | int | Decimal integer |
b | int | Binary |
o | int | Octal |
x / X | int | Hex (lowercase / uppercase) |
c | int | Character (Unicode codepoint) |
n | int / float | Locale-aware number |
f / F | float | Fixed-point decimal |
e / E | float | Scientific notation |
g / G | float | General (chooses f or e) |
% | float | Percentage (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.
Cost: Type-mismatched format codes raise ValueError at format time, not at parse time. f"{name:.2f}" where name is a string fails only when that line runs. Watch for this when format codes come from variables or config.
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.
, 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.
The sign field decides what shows up in front of a number:
| Sign | Behavior |
|---|---|
- | 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.
# Alternate FormThe # 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.
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.
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 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.
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.
Cost: str.format() parses the template string every call. For a hot path, build the formatted string with f-strings or pre-compile with string.Template if the template comes from a file.
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.
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.
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.
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.
Three places %-formatting still shows up:
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.% or .format(). New code should use f-strings unless there's a reason not to."$%.2f" % price is shorter than f"${price:.2f}" by a few characters, though most people still prefer the f-string for consistency.Cost: logging formats with %-style on purpose. log.debug("value=%s", expensive_obj) doesn't call str(expensive_obj) unless DEBUG logging is enabled. Writing log.debug(f"value={expensive_obj}") builds the string every call, even when DEBUG is off.
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:
| Feature | f-string | .format() | %-style |
|---|---|---|---|
| Python version | 3.6+ | 2.6+ | All versions |
| Speed | Fastest | Slow (parses template each call) | Fast |
| Inline expressions | Yes ({a + b}) | No | No |
Self-documenting ({x=}) | Yes (3.8+) | No | No |
| Template can be stored in a variable | No | Yes | Yes |
| Reads like a sentence | Yes | Mostly | Mostly |
Works with gettext/logging lazy eval | No | No | Yes (logging) |
| Brace escape | {{ }} | {{ }} | %% |
| Conversion flags | !r !s !a | !r !s !a | %r %s |
A simple decision rule:
logging calls where lazy evaluation matters. Don't use it for general string building in new code.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.
10 quizzes