The match statement compares a value against a
series of patterns and runs the first one that fits. A
match is far more than a switch
because a pattern can test a value’s shape, look inside it, and
pull out the parts you need, all in one step. No C-style
switch can express this:
match event:
case {"type": "click", "x": x, "y": y}:
...That case matches only a dictionary whose "type"
is "click", and it binds x and
y from that dictionary as it matches.
match becomes valuable once the patterns do more
than test equality.
Pattern matching first appeared in Control Flow.
match and case are soft
keywords: they act as keywords only inside this statement, so
existing code that uses match as a variable name
still runs. That is also a reason not to write such code. A
local named match shadows nothing, but it reads
like the statement.
The simplest patterns are literal values. A
case _ at the end is the wildcard. It matches
anything, like a default. Without one, a match that
fits no pattern does nothing and raises no error.
match tries the patterns top to bottom and the
first match wins: one case body runs, then the
statement ends. Unlike C’s switch, cases do not
fall through, and match has no break
to forget:
# http_status.py
def describe(status: int) -> str:
match status:
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Server Error"
case _:
return f"Status {status}"
print(describe(200))
#: OK
print(describe(404))
#: Not Found
print(describe(301))
#: Status 301A literal pattern compares with ==, not with
is, so case 200: also matches
200.0 and case 1: matches
True. None, True, and
False are the exception: those three compare with
is, so case True: does not match
1.
For a value-to-value lookup like this, a dictionary is often shorter (see When Not to Match).
An alternative combines several patterns in one
case with |. Every alternative in a
| must bind the same set of names.
A bare name is a capture pattern. Like
_, it matches any value unconditionally; unlike
_, it also binds the matched value to that name.
Here, other is the capture pattern:
# step.py
def step(command: str) -> str:
match command:
case "up" | "u":
return "y -= 1"
case "down" | "d":
return "y += 1"
case other:
return f"unknown command: {other}"
print(step("up"))
#: y -= 1
print(step("d"))
#: y += 1
print(step("jump"))
#: unknown command: jumpA bare name always binds. It does not compare against a
variable of that name, so a named constant in a
case silently captures instead. A value
pattern is a dotted name, and it does compare:
# value_patterns.py
from enum import Enum
from typing import Final
class Signal(Enum):
STOP = "stop"
GO = "go"
DEFAULT: Final[Signal] = Signal.STOP
def act(s: Signal) -> str:
match s:
case Signal.GO:
return "accelerate"
case Signal.STOP:
return "brake"
def broken(s: Signal) -> str:
match s:
case DEFAULT:
return f"DEFAULT is now {DEFAULT}"
return "unreachable"
print(act(Signal.GO), act(Signal.STOP))
#: accelerate brake
print(broken(Signal.GO))
#: DEFAULT is now Signal.GO
print(DEFAULT)
#: Signal.STOPcase Signal.GO compares.
case DEFAULT binds: it matches
Signal.GO, rebinds DEFAULT as a local
name inside broken(), and leaves the module-level
constant untouched. Python catches the mistake when a later
case follows a bare-name capture, refusing to
compile with
SyntaxError: name capture 'DEFAULT' makes remaining patterns unreachable.
When the capture is the last case, as here, Python
does not warn you. ruff does notice, reporting
N806 Variable DEFAULT in function should be lowercase:
DEFAULT here is a local variable rather than the
constant you meant to compare against.
act() also shows why an enum is worth the
trouble: Signal is a closed set, so the checker
sees that the cases cover both members and does not complain
about the missing return.
A sequence pattern matches the shape of a list or tuple and
binds the elements by position. A starred name, as in
*rest, captures the remaining elements:
# sequence_patterns.py
def summarize(items: list[int]) -> str:
match items:
case []:
return "Empty"
case [only]:
return f"One item: {only}"
case [first, second]:
return f"Two items: {first}, {second}"
case [first, *rest]:
return f"{first}, then {len(rest)} more"
case _:
return "Unreachable"
print(summarize([]))
#: Empty
print(summarize([5]))
#: One item: 5
print(summarize([3, 4]))
#: Two items: 3, 4
print(summarize([1, 2, 3, 4]))
#: 1, then 3 moresummarize() shows the structural part of
“structural pattern matching.” The pattern
[first, second] matches only a two-element sequence
and pulls both out at once. The last case _ never
runs: [first, *rest] catches every nonempty list
and [] the empty one. The checker cannot prove
that, so the wildcard stays to satisfy the declared return
type.
A sequence pattern deliberately excludes str,
bytes, and bytearray.
case [a, b, c] does not match "abc",
even though a string is a sequence in every other context.
Iterating a string a character at a time is rarely what a
pattern means, so the language rules it out. A tuple does match:
case [a, b, c] accepts (1, 2, 3) as
readily as [1, 2, 3], because the pattern describes
a shape, not a concrete type. Parentheses group a pattern rather
than build a tuple: case (x) is
case x, an unconditional capture, while
case (x,) is a one-element sequence pattern. The
subject must be a sequence, though, not merely iterable:
case [a, b] matches a range but not a
generator and not a set.
The brackets are optional in a sequence pattern, so
case 0, 0: and case [0, 0] are the
same pattern. The subject is any expression, not only a
parameter, and a comma builds a tuple there too, so
match sign(x), sign(y): matches on a pair computed
inline. Transforming the subject this way turns a set of
comparisons into literal patterns, which usually reads better
than the guards you would write otherwise.
# test_sequence_patterns.py
import pytest
from sequence_patterns import summarize
@pytest.mark.parametrize("items, expected", [
([], "Empty"),
([5], "One item: 5"),
([1, 2, 3], "1, then 2 more"),
])
def test_sequence_patterns(items: list[int], expected: str) -> None:
assert summarize(items) == expectedA class pattern matches by type and extracts attributes. With a data class you can match positionally or by keyword:
# point.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int# class_patterns.py
from point import Point
def locate(p: Point) -> str:
match p:
case Point(0, 0):
return "The origin"
case Point(0, y):
return f"On the y-axis at y={y}"
case Point(x, 0):
return f"On the x-axis at x={x}"
case Point(x, y):
return f"At ({x}, {y})"
print(locate(Point(0, 0)))
#: The origin
print(locate(Point(0, 5)))
#: On the y-axis at y=5
print(locate(Point(3, 0)))
#: On the x-axis at x=3
print(locate(Point(3, 4)))
#: At (3, 4)Point(0, 0) matches a point whose fields are
both zero. Point(0, y) matches when x
is zero and captures y. The literal and the capture
combine in one pattern.
Despite the call syntax, a class pattern builds nothing: it
tests the subject’s type and reads its attributes. Positional
matching depends on __match_args__, a class
attribute listing field names in order. @dataclass
generates it automatically from the field order, so
Point(0, y) means “position 0 is x,
position 1 is y.” NamedTuple generates
it too; an ordinary class must assign it by hand. Without a
__match_args__ long enough to cover the positions
you supply, a positional pattern raises a
TypeError. For an ordinary class R
that lacks one, case R(1) reports
TypeError: R() accepts 0 positional sub-patterns (1 given).
Keyword patterns work differently.
Point(x=0, y=y) matches by attribute name directly,
through attribute access, not through
__match_args__. They also work on any object with
the named attributes, dataclass or not, and let you match a
subset of attributes while ignoring the rest:
# keyword_patterns.py
from point import Point
def describe(p: Point) -> str:
match p:
case Point(x=0):
return "Somewhere on the y-axis"
case Point(y=0):
return "Somewhere on the x-axis"
case Point():
return "Just some point"
print(describe(Point(0, 5)))
#: Somewhere on the y-axis
print(describe(Point(3, 0)))
#: Somewhere on the x-axis
print(describe(Point(3, 4)))
#: Just some pointPoint(x=0) matches any point whose
x attribute is zero, ignoring y. A
positional pattern can leave fields unchecked too:
Point(0) supplies fewer sub-patterns than
__match_args__ names, so it ignores y,
and Point(_, 0) uses the wildcard to skip
x. Naming the attribute is clearer, and it survives
a change to the field order that would silently redefine every
position. Point() with no arguments, keyword or
positional, matches any Point instance: use it as a
type-only check or a final catch-all.
The type test is isinstance(), so a subclass
matches its base’s pattern:
# type_patterns.py
def describe(value: object) -> str:
match value:
case bool(b):
return f"bool {b}"
case int(n):
return f"int {n}"
case str(s):
return f"str of length {len(s)}"
case _:
return "something else"
print(describe(True))
#: bool True
print(describe(7))
#: int 7
print(describe("hello"))
#: str of length 5
print(describe(3.5))
#: something elseBecause a subclass matches, the order of the cases decides
which one wins. bool is a subclass of
int, so moving case bool(b) below
case int(n) makes it unreachable:
describe(True) would answer
int True.
The single positional argument in int(n) does
not name an attribute. Python special-cases a handful of
builtins (bool, int,
float, str, bytes,
bytearray, list, tuple,
dict, set, frozenset) so
that one positional sub-pattern binds the whole value, which is
why case str(s) reads as “a string, call it
s.”
Dropping the parentheses flips the meaning:
case str: is a bare-name capture, not a type test,
matching any value and binding it to a local named
str. It is the same mistake as DEFAULT
in value_patterns.py, and Python catches it the
same way, refusing to compile a case that follows
it:
SyntaxError: name capture 'str' makes remaining patterns unreachable.
Matching on isinstance() is the opposite of the
exact-type dispatch that a dict keyed on
type(value) performs, which Multiple
Dispatching relies on. There a subclass finds no entry at
all.
# test_class_patterns.py
import pytest
from class_patterns import locate
from keyword_patterns import describe
from point import Point
@pytest.mark.parametrize("point, expected", [
(Point(0, 0), "The origin"),
(Point(3, 0), "On the x-axis at x=3"),
(Point(3, 4), "At (3, 4)"),
])
def test_class_patterns(point: Point, expected: str) -> None:
assert locate(point) == expected
@pytest.mark.parametrize("point, expected", [
(Point(0, 5), "Somewhere on the y-axis"),
(Point(3, 0), "Somewhere on the x-axis"),
(Point(3, 4), "Just some point"),
])
def test_keyword_patterns(point: Point, expected: str) -> None:
assert describe(point) == expectedA guard is an if attached to a
case. The case matches only when the pattern fits
and the guard is true:
# guards.py
from point import Point
def quadrant(p: Point) -> str:
match p:
case Point(0, 0):
return "Origin"
case Point(x, y) if x > 0 and y > 0:
return "First quadrant"
case Point(x, y) if x < 0 and y > 0:
return "Second quadrant"
case _:
return "Somewhere else"
print(quadrant(Point(0, 0)))
#: Origin
print(quadrant(Point(3, 4)))
#: First quadrant
print(quadrant(Point(-3, 4)))
#: Second quadrant
print(quadrant(Point(-1, -1)))
#: Somewhere elseThe guard runs only after the pattern matches, which lets it
use the names the pattern bound. A false guard moves on to the
next case, but the names stay bound: once
case Point(x, y) if x > 0 and y > 0 has
failed, x and y still hold the values
it captured. A pattern tests shape and equality, so everything
beyond that belongs in the guard: an ordering test like
x > 0, a relation between two captures like
x == y, or any call, such as
len(items) > 3. Repeating a name does not
express equality: case [x, x]: fails with
SyntaxError: multiple assignments to name 'x' in pattern,
so an equal-elements test is also a guard,
case [x, y] if x == y:. A guard that only compares
one capture to a constant is a literal pattern written the long
way.
A mapping pattern matches keys in a dictionary and binds
their values. It ignores keys you do not mention, so it
dispatches cleanly on JSON-shaped data. That also makes
case {} a catch-all for any mapping rather than a
test for an empty one, the opposite of case [],
which matches only an empty sequence. Test for an empty
dictionary with a guard, case {} if not event:. A
**rest at the end binds whatever keys the pattern
did not mention, the mapping counterpart of *rest
in a sequence pattern.
# mapping_patterns.py
def handle(event: dict[str, object]) -> str:
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "key", "key": key}:
return f"Key {key}"
case {"type": kind}:
return f"Other event: {kind}"
case unknown:
return f"Unrecognized event: {unknown}"
print(handle({"type": "click", "x": 10, "y": 20}))
#: Click at (10, 20)
print(handle({"type": "key", "key": "Enter"}))
#: Key Enter
print(handle({"type": "scroll", "delta": 3}))
#: Other event: scroll
print(handle({"button": 1}))
#: Unrecognized event: {'button': 1}The test checks a matched event and the fall-through:
# test_mapping_patterns.py
from mapping_patterns import handle
def test_mapping_patterns() -> None:
assert handle({"type": "key", "key": "Esc"}) == "Key Esc"
assert handle({"nope": 1}) == "Unrecognized event: {'nope': 1}"Each section so far introduced one pattern form on its own. A sub-pattern is itself a pattern, so any of these forms can sit inside any other:
# nested_patterns.py
from point import Point
def survey(points: list[Point]) -> str:
match points:
case [Point(0, 0) as start, *rest]:
return f"{start} then {len(rest)} more"
case [Point(0, n) | Point(n, 0)]:
return f"one axis point, offset {n}"
case [Point(), Point()]:
return "two points"
case _:
return "nothing to say"
print(survey([Point(0, 0), Point(1, 1), Point(2, 2)]))
#: Point(x=0, y=0) then 2 more
print(survey([Point(0, 5)]))
#: one axis point, offset 5
print(survey([Point(4, 0)]))
#: one axis point, offset 4
print(survey([Point(1, 2), Point(3, 4)]))
#: two pointsThe first case is a sequence pattern holding a class pattern
holding two literals, with a starred capture beside it.
as binds whatever its sub-pattern matched, so
start is the whole Point while
0, 0 checks its fields. Without as you
must choose between testing the shape and keeping the
object.
The second case alternates two class patterns and binds
n from either, inside a one-element sequence
pattern: survey([Point(0, 5)]) matches, while a
list of two points does not. The compiler enforces the
same-names rule stated earlier. Adding a third alternative
| Point(1, 1), which binds nothing, fails with
SyntaxError: alternative patterns bind different names.
When a value is one of a fixed set of types, define that set
as a union using the type
statement. Now you can match on that union.
When you end with case _: assert_never(value), the
type checker ensures the match is exhaustive. Adding a
type to the union and forgetting its case produces
a type error. The type checker reports it before the program
runs, instead of the value falling through at runtime. That is
the benefit of static typing applied to control flow:
# exhaustive.py
from dataclasses import dataclass
from math import pi
from typing import assert_never
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Square:
side: float
type Shape = Circle | Square
def area(shape: Shape) -> float:
match shape:
case Circle(radius):
return pi * radius ** 2
case Square(side):
return side ** 2
case _:
assert_never(shape)
print(round(area(Circle(1.0)), 4))
#: 3.1416
print(area(Square(2.0)))
#: 4.0If you add a Triangle to Shape
without adding the appropriate case, the checker
flags assert_never(shape).
assert_never() acts at runtime as well as at check
time. If a value that lied about its type reaches it, it raises
AssertionError: Expected code to be unreachable, but got: 'x',
naming the value it received.
A switch in C, JavaScript, or traditional Java
cannot do this. Nothing forces you to add a case, and an
unhandled value falls through silently. Scala’s
match, Kotlin’s when, and Java’s newer
switch expressions do check this, as an error in Java and Kotlin
and a warning in Scala, as long as the matched type is a sealed
hierarchy the compiler can see in full. Their versions are also
expressions, producing a value you can assign. Python’s
match is a statement, which is why every
match in this chapter sits inside a function that
returns from each case.
Python has no sealed keyword.
assert_never() plus a type checker fills that role
instead. An if/isinstance() chain can
reach the same guarantee, but only if you remember to end it
with assert_never(). A match makes the
shape of the dispatch explicit.
Shape turns the classic OOP “shapes” example
into a closed type union instead of a class hierarchy. Dynamic Binding
vs. Pattern Matching compares the two approaches
directly.
# test_exhaustive.py
from exhaustive import Circle, Square, area
def test_exhaustive_area() -> None:
assert round(area(Circle(1.0)), 4) == 3.1416
assert area(Square(2.0)) == 4.0For a value-to-value lookup, a dictionary is shorter:
# value_to_value_lookup.py
from typing import Final
STATUS: Final[dict[int, str]] = {
200: "OK", 404: "Not Found", 500: "Server Error"}
def describe(status: int) -> str:
try:
return STATUS[status]
except KeyError:
return f"Status {status}"
print(describe(200))
#: OK
print(describe(301))
#: Status 301The lookup is try/except rather
than STATUS.get(status, f"Status {status}") because
Python evaluates arguments before the call: every lookup builds
the default string, including the hits that discard it.
A literal match compiles to a chain of
comparisons, one per case, so its cost grows with
the number of cases while a dictionary lookup’s does not. At
three entries the difference does not matter; the dictionary
wins as the table grows, and it is the only one of the two you
can build or change at runtime.
When the set of types is open (anyone can add a new
one), inheritance and dynamic binding work better than
match. Each type carries its own behavior, so
adding a type needs no change to a central match.
Use match for a closed set of cases you want to
handle in one place, especially when the cases need to look
inside the value. When that closed set is a set of constants
rather than a set of shapes, make it an Enum and
match on its members, as
value_patterns.py did. The enum hands the checker
the closed set, so assert_never() works without a
type union.
An alerting system sends a notification through one of three channels: email, SMS, or push. Every channel renders the notification into a message string for a recipient. Every channel also has a rough cost to send a message.
The inheritance answer declares both operations as abstract methods on a base class. Each channel is a subclass that implements them, and dynamic binding picks the correct implementation at each call:
# notifications_oo.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import override
class Notification(ABC):
@abstractmethod
def render(self, recipient: str) -> str: ...
@abstractmethod
def cost(self) -> float: ...
@dataclass(frozen=True)
class Email(Notification):
subject: str
@override
def render(self, recipient: str) -> str:
return f"Email to {recipient}: {self.subject}"
@override
def cost(self) -> float:
return 0.001
@dataclass(frozen=True)
class Sms(Notification):
body: str
@override
def render(self, recipient: str) -> str:
return f"SMS to {recipient}: {self.body}"
@override
def cost(self) -> float:
return 0.02
@dataclass(frozen=True)
class Push(Notification):
title: str
@override
def render(self, recipient: str) -> str:
return f"Push to {recipient}: {self.title}"
@override
def cost(self) -> float:
return 0.0005
email = Email("Invoice ready")
sms = Sms("Code: 5821")
push = Push("New message")
print(email.render("Dana"))
#: Email to Dana: Invoice ready
print(sms.render("Dana"))
#: SMS to Dana: Code: 5821
print(push.render("Dana"))
#: Push to Dana: New message
print(round(email.cost() + sms.cost() + push.cost(), 4))
#: 0.0215Notification names the shape every channel must
have. @abstractmethod forces Email,
Sms, and Push to define both
render() and cost(). If you forget
one, the class stays abstract, so you cannot instantiate it.
A type union with match takes the opposite
shape. The channels become plain data, and each operation is a
free function that inspects the type:
# notifications_match.py
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Email:
subject: str
@dataclass(frozen=True)
class Sms:
body: str
@dataclass(frozen=True)
class Push:
title: str
type Notification = Email | Sms | Push
def render(note: Notification, recipient: str) -> str:
match note:
case Email(subject):
return f"Email to {recipient}: {subject}"
case Sms(body):
return f"SMS to {recipient}: {body}"
case Push(title):
return f"Push to {recipient}: {title}"
case _:
assert_never(note)
def cost(note: Notification) -> float:
match note:
case Email():
return 0.001
case Sms():
return 0.02
case Push():
return 0.0005
case _:
assert_never(note)
email = Email("Invoice ready")
sms = Sms("Code: 5821")
push = Push("New message")
print(render(email, "Dana"))
#: Email to Dana: Invoice ready
print(render(sms, "Dana"))
#: SMS to Dana: Code: 5821
print(render(push, "Dana"))
#: Push to Dana: New message
print(round(cost(email) + cost(sms) + cost(push), 4))
#: 0.0215render() and cost() each
match over Notification and end with
assert_never(), so the type checker confirms each
match handles every case.
# test_notifications.py
import notifications_match as nm
import notifications_oo as no
def test_oo_and_match_agree() -> None:
assert (no.Email("Hi").render("Dana")
== nm.render(nm.Email("Hi"), "Dana"))
assert no.Sms("Hi").cost() == nm.cost(nm.Sms("Hi"))Try growing the system in each direction. First, add a new
type: a Webhook channel. In the object version, you
write one new subclass with its own render() and
cost(), and nothing else changes. In the match
version, you add a Webhook dataclass to the
Notification union, and the type checker flags
assert_never() in both render() and
cost() until you add a
case Webhook(...) to each.
Now try adding a new operation, priority(), that
ranks channels by urgency. In the object version, every existing
subclass needs a new method. In the match version, you write one
new function with its own match, and the existing
classes and functions stay untouched.
Adding a type is cheaper with inheritance. Adding an operation is cheaper with pattern matching. That is the open-set-versus-closed-set tradeoff from When Not to Match, worked out concretely. It also has a name: the expression problem. Rethinking Objects works through the same split with shapes, and Multiple Dispatching and Visitor explore it further.
classify(value) that uses
match to return "empty list",
"singleton", or "longer list" for
lists, "point" for a Point, and
"other" for anything else.Rectangle type to
exhaustive.py’s Shape union without
adding its case. Run ty and read the
error it reports at assert_never.mapping_patterns.handle() to also
accept a nested shape, such as
{"type": "click", "at": {"x": x, "y": y}}, binding
x and y from the inner
dictionary.Webhook channel to
notifications_match.py: a dataclass with a
url field, added to the Notification
union. Run ty before adding its case
to render() and cost(), and read the
errors. Then add both cases and confirm ty
passes.guards.py’s quadrant() so
it handles the third and fourth quadrants too. Then write it a
second time with one case per sign combination,
using | alternations and no guards, and say which
version reads better.value_patterns.py’s Signal a
third member, and write act() so that it compares
against a module-level FALLBACK: Final[Signal]. Run
it and confirm that the constant captures instead of comparing.
Then fix it two ways, with a dotted name and with a guard.