Python, from the ground up Lesson 3 / 60

f-strings, walrus, match — the new flow-control idioms

Modern Python syntax that makes code clearer than the alternatives — when to use each, and the cases where the old way is still better.

Welcome to lesson three. Today we cover three syntactic features that have, between them, changed how a fluent Python developer writes day-to-day code: f-strings (3.6), the walrus operator (3.8), and pattern matching (3.10). None of them is mind-blowing on its own. All three together, used in the right places, are the difference between code that reads like 2026 and code that reads like the inside of a 2014 textbook.

A theme runs through all three: each one replaces a clunkier older idiom, but each one also has cases where the older idiom is still better. The skill is knowing which is which. We’ll work through each feature, the way you’ll actually use it, and the cases where reaching for it makes things worse, not better.

f-strings: the only string formatting you should reach for first

Before f-strings (Python 3.6, December 2016), you had three ways to format strings:

name = "Alice"
age = 30

# 1. The C-style "%" operator — older than dirt, still works
print("Hello, %s, you are %d" % (name, age))

# 2. The .format() method — added in 2.6, was the "modern" way until 3.6
print("Hello, {}, you are {}".format(name, age))

# 3. String concatenation — the "I just learned Python yesterday" way
print("Hello, " + name + ", you are " + str(age))

All three still work, all three still appear in old codebases, and all three are now wrong by default. The modern answer:

print(f"Hello, {name}, you are {age}")

Two characters of overhead (the f prefix), zero cognitive overhead, and the variables read in the order you say them out loud. f-strings are not just shorter than .format() — they’re noticeably faster (the bytecode is more direct), they integrate with the debugger more cleanly, and they handle nearly every case the older forms handled.

The full f-string toolkit

# Basic interpolation
user = "Alice"
greeting = f"Hello, {user}"  # "Hello, Alice"

# Expressions, not just variables
items = [1, 2, 3, 4, 5]
print(f"You have {len(items)} items, summing to {sum(items)}")

# Method calls and attribute access
import datetime
now = datetime.datetime.now()
print(f"Today is {now.strftime('%A')}")

# Format specifiers — same syntax as .format(), after a colon
price = 1234.5678
print(f"Price: {price:.2f}")              # "Price: 1234.57"
print(f"Price: {price:,.2f}")             # "Price: 1,234.57"  (thousand separator)
print(f"Pad: {price:>12.2f}")             # right-align in 12 columns
print(f"Pct: {0.4567:.1%}")               # "Pct: 45.7%"
print(f"Hex: {255:#x}")                   # "Hex: 0xff"

# Date formatting
print(f"Date: {now:%Y-%m-%d %H:%M}")      # "Date: 2026-05-01 14:30"

# The 3.8 debug syntax — print name AND value
x = 42
print(f"{x=}")                             # "x=42"
print(f"{len(items)=}")                   # "len(items)=5"

The {x=} syntax (3.8+) is one of those quietly transformative things. Replace ten years of print("x =", x) with print(f"{x=}") and you save keystrokes for the rest of your career. Use it constantly when debugging.

When NOT to use f-strings

Two cases. Memorize them.

Logging. This is wrong:

import logging
log = logging.getLogger(__name__)

# DON'T do this
log.info(f"Processing user {user_id} with payload {expensive_to_format(payload)}")

The f-string is evaluated before log.info is called. If your log level is set to WARNING and this is an INFO message, the message is discarded — but expensive_to_format(payload) already ran. The logging module deliberately uses lazy %-formatting for exactly this reason:

log.info("Processing user %s with payload %s", user_id, expensive_to_format(payload))

The arguments are only formatted if the message is actually going to be emitted. On a high-volume service this matters.

SQL queries (and any string going into another language’s parser). This is a vulnerability:

# NEVER do this. SQL injection.
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

Use parameterized queries. Always. Every database driver supports them. f-strings are the wrong tool for any string that crosses a language boundary into a parser — SQL, shell commands, HTML, anything. The right tool is the API designed to handle escaping for you.

For everything else — the 95% of strings you build in a normal program — f-strings are correct.

The walrus operator: assignment-as-expression

Python 3.8 added :=, officially called the “assignment expression” but universally known as the walrus operator (because := looks like a walrus, if you tilt your head and have the right kind of imagination).

It does one thing: assign a value to a name and return the value, in a single expression. That sounds boring until you see what it lets you avoid.

The classic case: read-and-test loops

# Pre-walrus
with open("data.log") as f:
    line = f.readline()
    while line:
        process(line)
        line = f.readline()

That f.readline() appears twice. Easy to forget one, easy to introduce a bug if you ever change how you read. The walrus collapses it:

# With walrus
with open("data.log") as f:
    while line := f.readline():
        process(line)

One reference to f.readline(), no duplication, the loop reads cleanly: “while line is something, process it.”

Other genuinely-good cases

Avoiding a redundant function call in a comprehension:

# Without walrus — calls expensive_compute(x) twice for items that pass
results = [expensive_compute(x) for x in inputs if expensive_compute(x) > threshold]

# With walrus — once
results = [y for x in inputs if (y := expensive_compute(x)) > threshold]

Compiled regex matching:

import re

pattern = re.compile(r"user_(\d+)")

# Without walrus
match = pattern.match(text)
if match:
    user_id = int(match.group(1))
    process_user(user_id)

# With walrus
if match := pattern.match(text):
    process_user(int(match.group(1)))

The body of the if clearly uses match, and the variable is bound only when there’s actually a match.

When NOT to use the walrus

Anywhere it makes the code harder to read. The walrus is a clarity tool, not a brevity tool. If using it forces a reader to backtrack to figure out where a variable came from, you’ve made it worse.

# This is worse than the alternative.
# The walrus is buried, the assignment is non-obvious,
# and you saved one line of code at the cost of a confused reader.
total = sum((y := x * 2) + 1 for x in range(10) if (y > 5))

A good rule: if you can’t explain what the walrus is doing in one short sentence, don’t use it. Most code doesn’t need it. Reach for it when there’s a real readability win — the file-read loop, the regex match, the avoid-recomputing-in-a-comprehension case — and skip it the rest of the time.

Pattern matching: not just a fancy switch

Python 3.10 (October 2021) added match and case. The framing in the early articles — “Python finally has a switch statement!” — undersold it. The C/Java switch is a glorified jump table on integer constants. Python’s match is structural pattern matching: it matches on the shape of data, destructures it into variables, and integrates with classes and dataclasses in a way that genuinely changes how some kinds of code are written.

The basic shape

def http_error_message(status: int) -> str:
    match status:
        case 200 | 201 | 204:
            return "OK"
        case 301 | 302:
            return "Redirect"
        case 400:
            return "Bad request"
        case 401 | 403:
            return "Unauthorized"
        case 404:
            return "Not found"
        case 500 | 502 | 503 | 504:
            return "Server error"
        case _:
            return f"Unknown status {status}"

That case _: is the wildcard, equivalent to default: in C. The | separates alternatives. So far this is, indeed, a fancy switch.

Where it gets interesting: shape matching

match shines when you’re branching on the shape of a value, not just its identity.

def handle_message(msg: dict) -> str:
    match msg:
        case {"type": "ping"}:
            return "pong"
        case {"type": "greeting", "name": name}:
            return f"Hello, {name}"
        case {"type": "command", "action": action, "args": [first, *rest]}:
            return f"Running {action} with {first} and {len(rest)} more args"
        case {"type": "error", "code": code} if code >= 500:
            return f"Server error: {code}"
        case {"type": "error", "code": code}:
            return f"Client error: {code}"
        case _:
            return "Unknown message"

Things to notice:

  • {"type": "greeting", "name": name} matches a dict with a "type" key equal to "greeting" and binds the value of "name" to a local variable called name.
  • [first, *rest] is list destructuring — first element to first, the rest to rest.
  • if code >= 500 is a guard — extra condition that has to be true for the case to match.
  • The order of cases matters. Python checks them top to bottom, takes the first match.

This is genuinely powerful for parsing JSON messages, walking AST nodes, dispatching on enum-like records, or any code that’s a chain of “if this dict has these keys with these shapes, do X” statements.

Class patterns

Pattern matching integrates with classes via the __match_args__ attribute (set automatically on dataclasses).

from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

@dataclass
class Triangle:
    base: float
    height: float

def area(shape: Circle | Rectangle | Triangle) -> float:
    match shape:
        case Circle(radius=r):
            return 3.14159 * r * r
        case Rectangle(width=w, height=h):
            return w * h
        case Triangle(base=b, height=h):
            return 0.5 * b * h

This is structurally similar to algebraic data types in Rust or Haskell. It’s not as expressive as those — Python’s pattern matching is intentionally less strict — but for the cases where it fits, it fits beautifully. Type checkers also use the match-class pattern to narrow types automatically.

Capture vs comparison: the gotcha

There is one trap that catches everyone exactly once. Watch:

HOST = "localhost"

def check(target: str) -> str:
    match target:
        case HOST:
            return "matches host"
        case _:
            return "no match"

print(check("anything"))  # "matches host"  ← wait, what?

The case HOST: is not “match if target equals HOST.” A bare name in a case is a capture pattern — it binds whatever was in target to a new local name HOST (shadowing the module-level one). Every value matches.

To compare against a constant, you have to qualify it:

class Config:
    HOST = "localhost"

def check(target: str) -> str:
    match target:
        case Config.HOST:           # dotted name = comparison
            return "matches host"
        case _:
            return "no match"

Or use a literal directly. Or enum.Enum members. The rule: dotted names compare, bare lowercase names capture. Once you’ve been bitten by this once you’ll remember forever.

When NOT to use match

Two cases.

A simple if/elif is shorter and clearer. If you’re matching on two or three integer values with no destructuring, an if/elif chain is fine and arguably more readable. match shines at four-plus branches with shape matching, not at “is x equal to 1, 2, or 3.”

The data has no consistent shape. match is brilliant when your data has predictable patterns. If every case is case _ if some_complicated_condition:, you’ve reinvented if/elif with extra steps. The structure has to be there for match to add value.

Putting it together

Here’s a small function using all three features in a way that, in 2020, would have been three times as long:

import logging
import re

log = logging.getLogger(__name__)

USER_PATTERN = re.compile(r"user:(\d+)")

def parse_event(event: dict) -> str:
    match event:
        case {"type": "click", "target": target} if (m := USER_PATTERN.match(target)):
            user_id = int(m.group(1))
            log.info("Click on user %s", user_id)
            return f"User clicked: {user_id=}"
        case {"type": "view", "page": page}:
            log.info("Page view: %s", page)
            return f"Viewed {page}"
        case _:
            log.warning("Unknown event: %s", event)
            return "Unknown event"

f-strings for the human-facing return values and the debug {user_id=}. Walrus inside the if guard to bind the regex match. Pattern matching to dispatch on the event shape. Lazy %-formatting in the log calls because that’s what logging wants.

Wrap-up

f-strings: default for human-facing strings, except logging and SQL. Use {x=} for debugging, you’ll thank me. Walrus: reach for it on read-and-test loops and where it removes duplication; skip it when it costs readability. match: shines on shape-matching and structured data; not a switch, don’t think of it as one; remember the bare-name capture trap.

Lesson 4 (Friday) is on iterators, generators, and the iteration protocol. The actual engine room of Python — once you understand it, half the standard library suddenly makes sense.

Further reading

Search