Index Ai Academy
Phase 7 · Software engineering · Python in depth

Pattern matching

match and case: literal, sequence, mapping and class patterns with guards.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Programs constantly look at a piece of data and decide what to do based on its shape: an API event with a "type" field, a command a user typed, a message from a model with different kinds of content. Python's match statement (since 3.10) checks the shape and pulls out the parts in one step. Chains of if, isinstance and dictionary lookups become a readable table of cases.

What you'll be able to do

  • Write match statements with literal, capture and wildcard patterns
  • Match the shape of lists and dictionaries, and pull values out of them in one step
  • Match objects by class and attributes, and add conditions with guards
  • Know when match makes code clearer, and when a plain if is better

What it covers

  1. match and case
  2. Matching the shape of a list
  3. Matching dictionaries: events and API messages
  4. Matching objects
  5. Shipping rules by shape
  6. When to use match, and when not to
  7. Comparing with constants: enums
  8. Worked example: a tiny command interpreter for the shop's stockroom

match and case

A match statement compares one value, the subject, against a list of case patterns, top to bottom. The first pattern that fits wins, and only its block runs:

def describe(status):
    match status:
        case 200 | 201:
            return "success"
        case 404:
            return "not found"
        case 429 | 503:
            return "busy: try again later"
        case code if 500 <= code < 600:
            return f"server error {code}"
        case _:
            return "something else"

for code in [200, 201, 404, 503, 502, 302]:
    print(code, describe(code))
It runs right here in your browser, with nothing to install.
  • A literal pattern like 404 matches that exact value. | means "or".
  • A bare name like code is a capture pattern: it matches anything and stores it under that name.
  • if ... after a pattern is a guard: the case only matches if the condition is also true.
  • _ is the wildcard: it matches anything and stores nothing, so it works as the "otherwise" case.

If no case matches and there's no _, nothing happens: match doesn't raise an error, so a missing case can slip through quietly. A final case _: that raises or logs makes the gap visible.

Matching the shape of a list

Sequence patterns match lists and tuples by their length and contents, and capture the parts. This suits text commands, which split into a list of words:

def run(command):
    match command.split():
        case ["help"]:
            return "commands: add SKU [QTY], remove SKU, list, quit"
        case ["add", sku]:
            return f"added 1 x {sku}"
        case ["add", sku, qty] if qty.isdigit():
            return f"added {qty} x {sku}"
        case ["remove", sku]:
            return f"removed {sku}"
        case ["list" | "ls"]:
            return "listing the cart"
        case ["quit" | "exit", *_]:
            return "bye"
        case [verb, *rest]:
            return f"unknown command {verb!r} with {len(rest)} argument(s)"
        case []:
            return "type a command"

for line in ["help", "add TE-101", "add PA-108 3", "add PA-108 three", "ls", "exit now", "buy it", ""]:
    print(repr(line), "->", run(line))

["add", sku] matches a two-word list whose first word is "add", and captures the second. *rest captures any number of remaining items, like *args in a function. Notice "add PA-108 three" fails the guard on the three-word case and falls through to the general [verb, *rest] case, so order the cases from most specific to most general.

Try it now

Step through the second call: the three-word case's shape fits but its guard fails, so matching moves on to the general case.

Quick check
What does this print?
def f(x):
    match x:
        case [a, b]:
            return a + b
        case [a, *rest]:
            return len(rest)
        case _:
            return -1
print(f([1, 2]), f([1, 2, 3]), f("no"))
  1. 3 2 2
  2. 3 2 -1
  3. 3 3 -1
  4. -1 2 -1
Show the answer

3 2 -1

[1, 2] fits [a, b]. [1, 2, 3] has three items, so it falls to [a, *rest] with two left over. A string never matches a sequence pattern in match, so "no" hits the wildcard.

Matching dictionaries: events and API messages

Mapping patterns match dictionaries by the keys they name, and ignore any other keys. That's exactly how events from APIs and webhooks look: a type field says which kind, and each kind has its own fields.

def handle(event):
    match event:
        case {"type": "order.created", "order_id": oid, "total": total} if total >= 500:
            return f"big order {oid}: notify the manager"
        case {"type": "order.created", "order_id": oid}:
            return f"new order {oid}"
        case {"type": "order.refunded", "order_id": oid, "reason": str(reason)}:
            return f"refund on {oid}: {reason}"
        case {"type": "stock.low", "items": [first, *more]}:
            return f"low stock: {first} and {len(more)} more"
        case {"type": str(kind)}:
            return f"ignored event {kind}"
        case _:
            raise ValueError(f"not an event: {event!r}")

events = [
    {"type": "order.created", "order_id": 50231, "total": 1240, "store": "online"},
    {"type": "order.created", "order_id": 50232, "total": 60},
    {"type": "order.refunded", "order_id": 50101, "reason": "arrived damaged"},
    {"type": "stock.low", "items": ["TE-101", "PA-108", "FO-120"]},
    {"type": "customer.updated", "id": 9},
]
for e in events:
    print(handle(e))

{"type": "order.created", "order_id": oid} matches any dictionary whose "type" is that string and which has an "order_id" key, capturing its value; extra keys like "store" are ignored. str(reason) matches only if the value is a string (it's a class pattern, covered next). Patterns nest, so "items": [first, *more] checks the list inside the dictionary too.

Matching objects

A class pattern like Point(x=0) matches objects of that class, and checks or captures their attributes. Dataclasses work with it straight away:

from dataclasses import dataclass

@dataclass
class Text:
    body: str

@dataclass
class Image:
    url: str
    width: int

@dataclass
class ToolCall:
    name: str
    args: dict

def render(block):
    match block:
        case Text(body=""):
            return "(empty text)"
        case Text(body=body):
            return body
        case Image(width=w, url=url) if w > 1000:
            return f"[large image {url}, will be resized]"
        case Image(url=url):
            return f"[image {url}]"
        case ToolCall(name="get_weather", args={"trailhead": place}):
            return f"(checking the weather at {place})"
        case ToolCall(name=name):
            return f"(calling tool {name})"

reply = [Text("Here's the forecast:"), ToolCall("get_weather", {"trailhead": "fern-hollow"}),
         Image("map.png", 2400), Text("")]
for block in reply:
    print(render(block))

This is the shape of code that handles a language model's reply, where each content block is text, an image or a tool call. The class pattern checks the type, like isinstance, and the keyword arguments check or capture attributes, all in one line. Built-in types work as patterns too: int(n), str(s), list().

Drill

Shipping rules by shape

Write fee(order) with a match on the order dictionary: orders with "member": True ship free; otherwise "dest": "domestic" costs 6.0 if "total" is under 50 and 0.0 otherwise; "dest": "international" costs 25.0; anything else raises ValueError.

def fee(order):
    pass


print(fee({"dest": "domestic", "total": 30}), fee({"dest": "domestic", "total": 80, "member": True}))
In the lesson, your code is checked as you write it.

When to use match, and when not to

match shines when you're branching on the shape of data and pulling pieces out of it: events with a type field, commands, parsed messages, tree structures. One case replaces an isinstance check, a few key lookups and the variable assignments that would follow them.

For simple comparisons it adds nothing. if price > 100: is clearer than a match with a guard. A dictionary from keys to functions (from the functions-as-values lesson) is often better when every case just calls a different handler.

Two traps catch newcomers:

  • A bare name always captures. case MAX_QTY: doesn't compare with a constant called MAX_QTY; it matches anything and rebinds the name. To compare with a constant, use a dotted name like case Limits.MAX_QTY: or an enum member like case Color.RED:.
  • Strings aren't sequences in patterns. case [first, *rest]: doesn't match the string "add", even though a string is a sequence elsewhere in Python. Split it into a list first.

Finally, remember that match checks shape, not truth. A mapping pattern only proves the keys exist: a "total" of "lots" or None still matches {"total": total}. Where the type of a value matters, say so in the pattern with int(total) or str(name), or check it with a guard, especially for data that came from outside the program, such as a webhook or a file.

Comparing with constants: enums

The bare-name trap has a clean fix. An enum gives a fixed set of named values, and because its members are written with a dot (Status.SHIPPED), match compares with them instead of capturing:

from enum import Enum

class Status(Enum):
    PLACED = "placed"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    RETURNED = "returned"

def next_step(status, days_since):
    match status:
        case Status.PLACED if days_since > 2:
            return "chase the warehouse"
        case Status.PLACED:
            return "wait"
        case Status.SHIPPED | Status.DELIVERED:
            return "nothing to do"
        case Status.RETURNED:
            return "refund"

for status, days in [(Status.PLACED, 1), (Status.PLACED, 4), (Status("delivered"), 9), (Status.RETURNED, 0)]:
    print(status.name, "->", next_step(status, days))

Status("delivered") turns a string from a database or an API into the enum member, and raises ValueError for anything that isn't a real status. That catches bad data at the edge of the program, instead of letting a typo like "delivred" quietly match nothing deep inside it. Enums and match work well together: every possible value has a name, and each case reads as one rule of the business.

Worked example

Worked example: a tiny command interpreter for the shop's stockroom

Parse typed commands into actions on a stock dictionary, with helpful errors for anything unexpected.

stock = {"TE-101": 4, "PA-108": 0}

def execute(line):
    match line.strip().lower().split():
        case ["stock", sku]:
            return f"{sku.upper()}: {stock.get(sku.upper(), 'unknown')}"
        case ["receive", sku, qty] if qty.isdigit() and int(qty) > 0:
            stock[sku.upper()] = stock.get(sku.upper(), 0) + int(qty)
            return f"received {qty} of {sku.upper()}"
        case ["receive", *_]:
            return "usage: receive SKU QTY (QTY a positive whole number)"
        case ["empty"]:
            return ", ".join(sku for sku, n in stock.items() if n == 0) or "nothing is out of stock"
        case [verb, *_]:
            return f"unknown command {verb!r}"
        case []:
            return "type a command"

for line in ["stock te-101", "receive pa-108 6", "receive pa-108 lots", "empty", "STOCK PA-108", "sell te-101", "  "]:
    print(f"{line!r:24} -> {execute(line)}")
In the lesson you can change this and run it.

The subject is the cleaned, split line, so every case works with a list of words. The receive case with a guard handles the valid form, and the second receive case catches every malformed version with a usage message, which is friendlier than "unknown command". The general [verb, *_] case and the empty-list case cover the rest, so every input gets an answer. Each case reads as one rule of the stockroom's little language.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does case LIMIT: do when LIMIT is a module-level constant?

  1. It captures anything into a new variable called LIMIT, rather than comparing
  2. It compares the subject with LIMIT's value
  3. It raises a SyntaxError
  4. It matches only if LIMIT is True

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson