1.2.13 · D2Introduction to Programming (Python)

Visual walkthrough — f-strings — embedding expressions

1,863 words8 min readBack to topic

We build from zero. If you have never seen a variable, a string, or a curly brace used this way, start at Step 1 and read straight down.


Step 1 — What a plain string already is

WHAT. A string is just a row of characters the computer stores in order, like beads on a wire. Writing "Hi Ada" means: store the beads H, i, space, A, d, a. See Strings — literals and quotes for the full story of quotes.

WHY. Before we can "fill blanks," we must be sure of what a string is without any magic: a fixed row of characters, nothing gets computed. The quotes "..." mark where the row starts and ends.

PICTURE. Each character sits in its own cell. There is nothing to evaluate — what you type is what you get.

Figure — f-strings — embedding expressions

Step 2 — Adding the f stamp turns some cells into "blanks"

WHAT. Put the letter f immediately before the opening quote: f"Hi {name}". Now the two brace characters { and } stop being ordinary beads. They mark a blank — a region whose contents are code to run, not text to copy.

WHY this and not concatenation? The old way, "Hi " + name, keeps the text and the value in two separate places joined by +. The f stamp lets the value live right where it appears in the sentence, so what you read left-to-right is what you get. See str.format() method for the older sibling that used {} without the value inside.

PICTURE. The amber f is a stamp on the whole string. Cells outside the braces stay white (literal). The region between { and } turns cyan — that is a slot waiting to be evaluated.

Figure — f-strings — embedding expressions

Step 3 — The scanner walks left to right

WHAT. Python reads the f-string once, character by character, from the first bead to the last. It keeps a growing output that starts empty.

WHY a single left-to-right pass? Because text has an order. "Hi" must come before the name, which must come before the "!". A one-way scan preserves that order automatically — no re-ordering, no matching {} to arguments by position (the mistake that plagued .format()).

PICTURE. A cyan cursor sits under the current character. Two modes are shown as two lanes: COPY mode (white) copies the character straight into output; READ mode (cyan) collects an expression until the matching }.

Figure — f-strings — embedding expressions

The rule the cursor follows:


Step 4 — Hitting {: grab the expression and run it

WHAT. When the cursor hits {, it switches to READ mode and keeps reading until the matching }. Everything collected in between — call it the expression text — is handed to Python and executed as an expression in the current scope.

WHY "expression" and not "statement"? An expression is anything that has a value (like name, or a + b, or word.upper()). A statement (like x = 5) does something but has no value to insert. The blank needs a value, so only expressions are allowed. This is exactly why f"{x = 5}" fails — see the parent's mistakes. Variables here are looked up in the current scope.

PICTURE. The cyan slot {name} is lifted out, evaluated in a little "engine," and it returns the value "Ada".

Figure — f-strings — embedding expressions

The left side is the source code you typed inside the braces; the arrow is Python running it; the right side is the resulting object.


Step 5 — Convert the value to text, then append

WHAT. The value that came back (here the string "Ada"; but it could be a number, a list, anything) is turned into text and appended to the output. By default this uses str(...) — really format(value, "").

WHY convert at all? The output is a string, a row of characters. A number like 36 is not characters yet — it must be rendered into the beads 3 and 6. This automatic conversion is exactly the str() call you used to forget in the old "Hi " + str(age) way. See str() and type conversion.

PICTURE. The value 36 passes through a converter box and comes out as the two-character string "36", which is then glued onto the end of the output.

Figure — f-strings — embedding expressions


Step 6 — The colon: dressing the value in a costume

WHAT. Inside a blank you may write {value:spec}. Everything after the colon is a format specification — instructions for how to render the value: decimals, padding, thousands separators.

WHY a separate spec? The value and its appearance are different questions. pi is always the same number, but you might want 3.14 here and 3.14159 there. The colon splits "which value" (left) from "how to show it" (right). The full grammar lives in Format specification mini-language.

PICTURE. The blank {pi:.2f} splits at the colon: the left part pi is evaluated to 3.14159…, the right part .2f is the costume applied by format.

Figure — f-strings — embedding expressions

  • .2f — the f means "float"; the .2 means "two digits after the decimal point."
  • , — insert thousands separators, so 1234567 → "1,234,567".
  • 05d — the d means "integer"; 05 means "pad with zeros to width 5," so 42 → "00042".

Step 7 — The degenerate cases (never let the reader get stuck)

WHAT. Three inputs look like they break the machine. Watch each one:

WHY show these? A one-way scanner has exactly one ambiguity: what if you want a real brace, or forget the f, or the value is empty? We cover all three so no scenario surprises you.

PICTURE. Three mini-scans side by side, each ending in the correct output.

Figure — f-strings — embedding expressions
  1. Literal brace — a single { starts a slot, which is not what you want when writing set notation. Double it: {{ tells the scanner "output one literal {." So f"{{1, 2}}"{1, 2}.
  2. No f prefix — with no stamp, the braces never become slots; they are copied. "{name}" → the four characters {name}. Nothing evaluates.
  3. Empty / debug form{x=} (a special Python 3.8+ shortcut) prints both the source text x= and the value, e.g. x=9. It is the one place = is legal in a blank, because it is a fixed debug marker, not an assignment.

Step 8 — Reassemble: join all the pieces in order

WHAT. After the scan finishes, the output is the concatenation, in scan order, of every literal chunk and every converted value.

WHY this closes the loop. This is precisely the substitution law the parent stated. Now you have seen why it is true: the scanner appended each piece the moment it produced it, so the final string is just those pieces glued in order.

PICTURE. The finished conveyor belt: literals and evaluated values interleaved into one final string sent to print().

Figure — f-strings — embedding expressions

Each is a run of literal characters; each is an expression; each is its (possibly empty) format spec. The join simply pastes them left to right.


The one-picture summary

The whole derivation on one canvas: the f stamp arms the string, the cursor scans once, literals are copied, {...} slots are evaluated → converted → appended, and everything is joined into the final text.

Figure — f-strings — embedding expressions
Recall Feynman retelling in plain words

Picture a fill-in-the-blank story card. The letter f is a rubber stamp on the card that says "the blanks are alive." A tiny reader walks along the card left to right with a finger. On ordinary letters, the finger just copies them onto a fresh sheet. When the finger reaches a {, it stops, reads everything up to the matching }, and hands that little snippet to a calculator. The calculator works out the answer (a name, a sum, whatever), a converter turns that answer into plain letters, and the reader writes those letters onto the fresh sheet before walking on. If you actually want to draw a curly brace, you write two of them so the reader knows you mean a real brace, not a blank. If you forget the stamp, the reader treats { and } as ordinary letters and just copies them. When the reader reaches the end of the card, the fresh sheet holds the finished sentence — every blank already filled, every value already dressed in whatever costume you asked for after the colon.

Read direction
Left to right, one character at a time, in a single pass.
Two things to a value
It is evaluated (run as code), then converted to text via format(value, spec).
Expression not statement
The blank needs a value to insert; a statement like x = 5 produces no value.
After the colon
The format specification (how to render the value); format() applies it.