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.
1# The isinstance ladder: type check, then extract, then act2def handle_event(event):3 if isinstance(event, MouseClick):4 x, y = event.x, event.y5 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.height18 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.
1# The dict dispatch version: flatter, but loses access to event fields2def handle_mouse_click(event): ...3def handle_key_press(event): ...4def handle_resize(event): ...56handlers = {7 "MouseClick": handle_mouse_click,8 "KeyPress": handle_key_press,9 "WindowResize": handle_resize,10}1112def 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.
1# Literal patterns: match exact values2def 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 anything13 return "Unknown"1415print(http_status(200)) # OK16print(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.
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 else8 print(f"positive: {n}")910describe(0) # zero11describe(-5) # negative: -512describe(42) # positive: 42
OR patterns
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"1314print(classify_status(201)) # success15print(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
1from dataclasses import dataclass23@dataclass4class Point:5 x: float6 y: float78def 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})"2021print(describe_point(Point(0, 0))) # origin22print(describe_point(Point(0, 5))) # on y-axis at 5.023print(describe_point(Point(3, 3))) # on diagonal at 3.024print(describe_point(Point(2, 7))) # at (2.0, 7.0)
Sequence patterns
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 elements12 return f"saying: {' '.join(words)}"13 case _:14 return f"unknown: {tokens}"1516print(parse_command([])) # empty17print(parse_command(["go", "north"])) # going north18print(parse_command(["go", "north", "fast"])) # going north at fast19print(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.
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}"1314print(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.
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"1112print(classify(0)) # zero13print(classify(4)) # 4 is even and positive14print(classify(7)) # 7 is odd and positive15print(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.
1from dataclasses import dataclass23@dataclass4class MouseClick:5 x: int6 y: int7 button: str89@dataclass10class KeyPress:11 key: str1213@dataclass14class WindowResize:15 width: int16 height: int1718def 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}")3435print(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.