Structural Pattern Matching

Reviewed & published by Brayan K

Structural pattern matching is Python's match/case statement (3.10+) that branches on the shape of data — the structure of a list, dict, or object — while pulling out its pieces in the same step.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

Think of it as a far smarter if/elif chain: it can destructure sequences, mappings, and class instances, capture values, use a wildcard, and add guard conditions.

What You'll Learn in This Lesson

1 Literals, Capture, and the Wildcard _

A match tests a value against each case top to bottom. A bare name captures the value; _ is the catch-all default:

def describe(status):
    match status:
        case 200:                 # literal pattern
            return "OK"
        case 404:
            return "Not Found"
        case 500 | 502 | 503:     # OR pattern with |
            return "Server Error"
        case code:                # capture pattern (binds 'code')
            return f"Unknown code: {code}"

print(describe(200))     # OK
print(describe(503))     # Server Error
print(describe(418))     # Unknown code: 418
# Map HTTP-style commands using literals, OR-patterns, and _
def handle(command):
    match command:
        case "start":
            return "Starting up"
        case "stop" | "halt":      # OR pattern
            return "Shutting down"
        case "":
            return "Empty command"
        case other:                # capture anything else
            return f"Unknown: {other}"

print(handle("start"))    # Starting up
print(handle("halt"))     # Shutting down
print(handle(""))         # Empty command
print(handle("dance"))    # Unknown: dance

# ✅ Expected output:
# Starting up
# Shutting down
# Empty command
# Unknown: dance

📦 Sequence Patterns

Patterns can match the structure of a list or tuple and bind its parts — including a *rest to soak up the remainder:

def parse(point):
    match point:
        case []:
            return "empty"
        case [x]:                 # exactly one item
            return f"single: {x}"
        case [x, y]:              # exactly two items
            return f"pair: {x}, {y}"
        case [first, *rest]:      # one or more; *rest gets the tail
            return f"first={first}, rest={rest}"
        case _:
            return "not a list"

print(parse([]))            # empty
print(parse([9]))           # single: 9
print(parse([1, 2]))        # pair: 1, 2
print(parse([1, 2, 3, 4]))  # first=1, rest=[2, 3, 4]

Sequence patterns match lists and tuples by length and shape. The *rest star works just like unpacking — it captures however many items are left over.

2 Mapping Patterns, Class Patterns & Guards

Match dictionaries by their keys, class instances by their fields, and refine any case with an if guard:

def route(event):
    match event:
        # Mapping pattern: match by keys, bind the values
        case {"type": "click", "x": x, "y": y}:
            return f"Click at ({x}, {y})"
        case {"type": "key", "code": code}:
            return f"Key {code}"
        # Guard: an extra if condition on the case
        case {"type": "scroll", "delta": d} if d > 0:
            return "Scroll up"
        case {"type": "scroll"}:
            return "Scroll down"
        case _:
            return "unknown event"

print(route({"type": "click", "x": 10, "y": 20}))   # Click at (10, 20)
print(route({"type": "key", "code": "Enter"}))      # Key Enter
print(route({"type": "scroll", "delta": 3}))        # Scroll up
print(route({"type": "scroll", "delta": -1}))       # Scroll down
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

def locate(p):
    match p:
        case Point(x=0, y=0):           # class pattern, literal fields
            return "origin"
        case Point(x=0, y=y):           # capture y
            return f"on Y axis at {y}"
        case Point(x=x, y=0):
            return f"on X axis at {x}"
        case Point():
            return "somewhere else"

print(locate(Point(0, 0)))   # origin
print(locate(Point(0, 7)))   # on Y axis at 7
print(locate(Point(4, 0)))   # on X axis at 4
# Mapping patterns with a guard to interpret commands
def run(cmd):
    match cmd:
        case {"action": "move", "dist": d} if d > 0:
            return f"Move forward {d}"
        case {"action": "move", "dist": d}:
            return f"Move back {-d}"
        case {"action": "say", "text": t}:
            return f"Saying: {t}"
        case _:
            return "no-op"

print(run({"action": "move", "dist": 5}))    # Move forward 5
print(run({"action": "move", "dist": -3}))   # Move back 3
print(run({"action": "say", "text": "hi"}))  # Saying: hi
print(run({"action": "spin"}))               # no-op

# ✅ Expected output:
# Move forward 5
# Move back 3
# Saying: hi
# no-op

🎯 Your Turn: Fill in the Blanks

Replace each ___ to complete the patterns so the output matches.

# 🎯 YOUR TURN: replace each ___ to match the expected output
def classify(value):
    match value:
        case [___]:                 # 1. match a 2-element list, bind a and b
            return f"pair {a}+{b}={a + b}"
        case {"name": ___}:         # 2. bind the name value into n
            return f"hello {n}"
        case ___:                   # 3. catch-all
            return "other"

print(classify([2, 3]))             # pair 2+3=5
print(classify({"name": "Ada"}))    # hello Ada
print(classify(42))                 # other

# Expected output:
# pair 2+3=5
# hello Ada
# other
case [a, b]:                # 1. two-element sequence pattern
    return f"pair {a}+{b}={a + b}"
case {"name": n}:           # 2. mapping pattern binding n
    return f"hello {n}"
case _:                     # 3. wildcard catch-all
    return "other"

Common Errors & How to Fix Them

❌ Expecting a bare name to compare, not capture

PENDING = "pending"
match status:
    case PENDING:   # does NOT compare to "pending" - it CAPTURES!

✅ A bare name always captures. To compare against a constant, use a dotted name (case Status.PENDING:) or a literal (case "pending":).

❌ Putting the catch-all case first

match x:
    case _:        # matches everything - later cases never run!
        ...
    case 200:
        ...

✅ Cases run top to bottom. Put case _: (and other broad patterns) last.

❌ Using it on Python 3.9 or older

# SyntaxError: match/case needs Python 3.10+

✅ Structural pattern matching arrived in Python 3.10. On older versions, fall back to if/elif and isinstance.

🎯 Mini Challenge: Command Interpreter

Build a tiny interpreter that matches command lists of different shapes.

# Mini Challenge: interpret commands given as lists
def interpret(cmd):
    match cmd:
        case ["go", direction]:
            return f"Going {direction}"
        case ["repeat", n, *actions] if n > 0:
            return f"Repeat {n}x: {actions}"
        case ["quit"]:
            return "Bye!"
        case []:
            return "Nothing to do"
        case _:
            return "Bad command"

print(interpret(["go", "north"]))            # Going north
print(interpret(["repeat", 3, "jump", "spin"]))  # Repeat 3x: ['jump', 'spin']
print(interpret(["quit"]))                   # Bye!
print(interpret([]))                         # Nothing to do
print(interpret(["fly"]))                    # Bad command

# Expected output:
# Going north
# Repeat 3x: ['jump', 'spin']
# Bye!
# Nothing to do
# Bad command

📚 Keep Learning

Go deeper with the official Python documentation:

Frequently Asked Questions

Lesson complete — you match on shape now!

You can branch with match/case using literal, capture, and wildcard patterns, destructure sequences and mappings, match class instances, and refine cases with if guards.

Practice quiz

In match/case, what does a bare name pattern like 'case code:' do?

  • Compares the subject to a variable named code
  • Raises an error because names are not allowed
  • Always matches and captures the subject into code
  • Only matches if code was defined earlier

Answer: Always matches and captures the subject into code. A bare name is a capture pattern: it always matches and binds the subject to that name.

Which Python version first introduced structural pattern matching (match/case)?

  • Python 3.10
  • Python 3.8
  • Python 3.9
  • Python 3.12

Answer: Python 3.10. match/case was added in Python 3.10. Older versions raise a SyntaxError.

Given case 500 | 502 | 503:, what kind of pattern is this?

  • A guard pattern
  • A bitwise-or computation
  • A sequence pattern
  • An OR pattern matching any of the listed values

Answer: An OR pattern matching any of the listed values. The | combines alternative patterns, so the case matches 500, 502, or 503.

What does 'case [first, *rest]:' bind when matching [1, 2, 3, 4]?

  • first=[1, 2, 3] and rest=4
  • first=1 and rest=[2, 3, 4]
  • first=1 and rest=2
  • It does not match a 4-element list

Answer: first=1 and rest=[2, 3, 4]. The *rest star captures the remaining items, so first=1 and rest=[2, 3, 4].

What is the purpose of 'case _:' in a match statement?

  • It is a wildcard catch-all that matches anything not caught earlier
  • It matches only None
  • It captures the value into a variable named underscore
  • It marks the end of the match block

Answer: It is a wildcard catch-all that matches anything not caught earlier. The wildcard _ matches anything and does not bind a name, acting like a default branch.

Where should the catch-all 'case _:' be placed?

  • First, so it runs before other cases
  • Anywhere, order does not matter
  • Last, because cases are tested top to bottom
  • It must be the only case

Answer: Last, because cases are tested top to bottom. Cases run top to bottom; a catch-all placed first would prevent later cases from ever running.

Which case matches the dict {"type": "click", "x": 10, "y": 20}?

  • case {"type": "key", "code": code}:
  • case {"type": "click", "x": x, "y": y}:
  • case [type, x, y]:
  • case ("click", 10, 20):

Answer: case {"type": "click", "x": x, "y": y}:. A mapping pattern matches by keys and binds the corresponding values, so x=10 and y=20.

What does an if-guard add to a case, e.g. case {"delta": d} if d > 0:?

  • It loops the case until d > 0
  • It ignores the pattern and just checks the condition
  • It makes the case optional
  • It only matches when both the pattern matches AND the condition is true

Answer: It only matches when both the pattern matches AND the condition is true. A guard is an extra condition; the case fires only when the pattern matches and the guard is true.

For class patterns, what does 'case Point(x=0, y=0):' require?

  • Any Point instance regardless of fields
  • A Point whose x and y attributes both equal 0
  • A dict with keys x and y
  • A list of two zeros

Answer: A Point whose x and y attributes both equal 0. A class pattern matches an instance of Point and checks the given fields against the literals.

To compare a subject against a constant instead of capturing it, you should use:

  • A bare lowercase name like case pending:
  • A wildcard case _:
  • A literal or a dotted name, e.g. case "pending": or case Status.PENDING:
  • A *star pattern

Answer: A literal or a dotted name, e.g. case "pending": or case Status.PENDING:. Bare names capture; use a literal or a dotted (qualified) name to compare against a constant.

Continue this course

Frequently asked questions

What is structural pattern matching?

It's the match/case statement added in Python 3.10. Instead of just comparing equality like a switch, it matches the STRUCTURE of data — the shape of a list, the keys of a dict, the type and fields of an object — and can pull out (bind) pieces in the same step. It's like a much smarter if/elif chain that destructures as it tests.

How is match/case different from a switch statement?

A classic switch only checks one value against constants. Python's match goes far beyond that: it can match sequences ([x, y]), mappings ({'key': v}), class instances (Point(x=0)), bind captured names, use a wildcard _, and add if-guards. It's pattern matching, not just value switching, so it handles structured data a switch can't.

What does the underscore _ mean in a case?

The wildcard _ is the catch-all pattern: 'case _:' matches anything that no earlier case caught, like a default branch. It also matches without binding a name, so you can use _ inside larger patterns to mean 'something is here but I don't care what it is', e.g. case [first, _] ignores the second element.

When should I use match/case instead of if/elif?

Reach for match/case when you're branching on the SHAPE of data — different message types, command formats, JSON-like structures, or class instances you want to destructure. For a couple of simple boolean conditions, a plain if/elif is clearer. match shines when destructuring and type-based branching would otherwise need lots of indexing and isinstance checks.