Codetail

Article 2 of 8

Structural Pattern Matching

Replace your isinstance ladders. For good.

22 min read

The isinstance ladder

Branching on the shape or type of a value in pre-3.10 Python meant one of two things: a chain of if/elif isinstance() blocks, or a dispatch dict. Both work. Neither scales.

Python
1# The isinstance ladder: type check, then extract, then act
2def handle_event(event):
3 if isinstance(event, MouseClick):
4 x, y = event.x, event.y
5 if event.button == "left":
6 handle_left_click(x, y)
7 elif event.button == "right":
8 handle_right_click(x, y)
9 elif isinstance(event, KeyPress):
10 if event.key == "Escape":
11 close_dialog()
12 elif event.key == "Enter":
13 submit_form()
14 else:
15 buffer.append(event.key)
16 elif isinstance(event, WindowResize):
17 width, height = event.width, event.height
18 reflow(width, height)
19 else:
20 raise ValueError(f"unknown event: {event!r}")

Three problems. First, you check the type and extract the data in separate steps. Second, the nesting grows with every condition. Third, the else at the bottom is your only exhaustiveness check, and it only fires at runtime.

Python
1# The dict dispatch version: flatter, but loses access to event fields
2def handle_mouse_click(event): ...
3def handle_key_press(event): ...
4def handle_resize(event): ...
5
6handlers = {
7 "MouseClick": handle_mouse_click,
8 "KeyPress": handle_key_press,
9 "WindowResize": handle_resize,
10}
11
12def handle_event(event):
13 handler = handlers.get(type(event).__name__)
14 if handler is None:
15 raise ValueError(f"unknown event: {event!r}")
16 handler(event) # still have to unpack inside each handler

The dict version reduces nesting but does not fix the core problem: matching and destructuring are separate operations. You know what type you have before you can extract what you need from it. match/case does both at once.

match/case basics

match takes a value. Each case specifies a pattern. The first pattern that matches runs its block. If nothing matches, the block is skipped.

Python
1# Literal patterns: match exact values
2def http_status(code: int) -> str:
3 match code:
4 case 200:
5 return "OK"
6 case 201:
7 return "Created"
8 case 404:
9 return "Not Found"
10 case 500:
11 return "Internal Server Error"
12 case _: # wildcard: matches anything
13 return "Unknown"
14
15print(http_status(200)) # OK
16print(http_status(418)) # Unknown

Capture patterns

A bare name in a pattern is a capture: it matches anything and binds the value to that name in the case block.

Python
1def describe(value):
2 match value:
3 case 0:
4 print("zero")
5 case n if n < 0: # capture + guard (covered next section)
6 print(f"negative: {n}")
7 case n: # capture: matches anything else
8 print(f"positive: {n}")
9
10describe(0) # zero
11describe(-5) # negative: -5
12describe(42) # positive: 42

OR patterns

Python
1def classify_status(code: int) -> str:
2 match code:
3 case 200 | 201 | 202 | 204:
4 return "success"
5 case 301 | 302 | 307 | 308:
6 return "redirect"
7 case 400 | 401 | 403 | 404 | 422:
8 return "client error"
9 case 500 | 502 | 503 | 504:
10 return "server error"
11 case _:
12 return "other"
13
14print(classify_status(201)) # success
15print(classify_status(503)) # server error

Class, sequence, and mapping patterns

The real power is matching on structure. Class patterns check the type and extract attributes. Sequence patterns destructure lists and tuples. Mapping patterns pull keys from dicts.

Class patterns

Python
1from dataclasses import dataclass
2
3@dataclass
4class Point:
5 x: float
6 y: float
7
8def describe_point(p: Point) -> str:
9 match p:
10 case Point(x=0, y=0):
11 return "origin"
12 case Point(x=0, y=y):
13 return f"on y-axis at {y}"
14 case Point(x=x, y=0):
15 return f"on x-axis at {x}"
16 case Point(x=x, y=y) if x == y:
17 return f"on diagonal at {x}"
18 case Point(x=x, y=y):
19 return f"at ({x}, {y})"
20
21print(describe_point(Point(0, 0))) # origin
22print(describe_point(Point(0, 5))) # on y-axis at 5.0
23print(describe_point(Point(3, 3))) # on diagonal at 3.0
24print(describe_point(Point(2, 7))) # at (2.0, 7.0)

Sequence patterns

Python
1def parse_command(tokens: list[str]) -> str:
2 match tokens:
3 case []:
4 return "empty"
5 case ["quit"]:
6 return "quitting"
7 case ["go", direction]:
8 return f"going {direction}"
9 case ["go", direction, speed]:
10 return f"going {direction} at {speed}"
11 case ["say", *words]: # * captures remaining elements
12 return f"saying: {' '.join(words)}"
13 case _:
14 return f"unknown: {tokens}"
15
16print(parse_command([])) # empty
17print(parse_command(["go", "north"])) # going north
18print(parse_command(["go", "north", "fast"])) # going north at fast
19print(parse_command(["say", "hello", "world"])) # saying: hello world

Mapping patterns

Mapping patterns are partial: the dict only needs to contain the listed keys. Extra keys are ignored unless you capture them with **rest.

Python
1def handle_event(event: dict) -> str:
2 match event:
3 case {"type": "click", "x": x, "y": y, "button": "left"}:
4 return f"left-click at ({x}, {y})"
5 case {"type": "click", "x": x, "y": y}:
6 return f"click at ({x}, {y})"
7 case {"type": "keypress", "key": ("Enter" | "Return")}:
8 return "submit"
9 case {"type": "keypress", "key": key}:
10 return f"key: {key}"
11 case {"type": t, **rest}:
12 return f"unknown event type {t!r} with {rest}"
13
14print(handle_event({"type": "click", "x": 10, "y": 20, "button": "left"}))
15print(handle_event({"type": "keypress", "key": "Enter"}))

Guards and a real-world example

A guard adds a condition to a pattern with if. The pattern must match and the guard must be true for the case to run.

Python
1def classify(n: int) -> str:
2 match n:
3 case 0:
4 return "zero"
5 case n if n % 2 == 0 and n > 0:
6 return f"{n} is even and positive"
7 case n if n % 2 != 0 and n > 0:
8 return f"{n} is odd and positive"
9 case n:
10 return f"{n} is negative"
11
12print(classify(0)) # zero
13print(classify(4)) # 4 is even and positive
14print(classify(7)) # 7 is odd and positive
15print(classify(-3)) # -3 is negative

The isinstance ladder, rewritten

Back to the event handler from the opening section. The same logic in match/case: type checking, field extraction, and conditional logic all in one place, with no nesting.

Python
1from dataclasses import dataclass
2
3@dataclass
4class MouseClick:
5 x: int
6 y: int
7 button: str
8
9@dataclass
10class KeyPress:
11 key: str
12
13@dataclass
14class WindowResize:
15 width: int
16 height: int
17
18def handle_event(event) -> str:
19 match event:
20 case MouseClick(x=x, y=y, button="left"):
21 return f"left-click at ({x}, {y})"
22 case MouseClick(x=x, y=y, button="right"):
23 return f"right-click at ({x}, {y})"
24 case KeyPress(key="Escape"):
25 return "close dialog"
26 case KeyPress(key="Enter"):
27 return "submit form"
28 case KeyPress(key=k):
29 return f"buffer: {k}"
30 case WindowResize(width=w, height=h):
31 return f"reflow to {w}x{h}"
32 case _:
33 raise ValueError(f"unknown event: {event!r}")
34
35print(handle_event(MouseClick(100, 200, "left")))
36print(handle_event(KeyPress("Enter")))
37print(handle_event(WindowResize(1920, 1080)))

The nesting is gone. Each case is a complete thought: type, fields, condition, action. Adding a new event type means adding a new case. Nothing else changes.

When to reach for match/case: any time you are branching on type, shape, or the content of a value. It is not a replacement for all conditionals, just the ones that would otherwise require a chain of isinstance checks and manual field extraction.