The worker’s chain of if message["type"] == ... runs to sixty lines, and the third branch also
checks status, total, and whether items is empty. Because two branches match the same message
and the wrong one runs first, a payment is recorded twice. Somebody rewrote it as a match with
case status: in it, which sent every message to that case.
What you get
You will end up with one match statement that routes wire dict messages and dataclass
instances to named handlers, and a table test that pins every message to the handler it reaches.
This is for you if
a Python worker decides what to do from the shape of a message.
Short answer
Write one match over the message with a mapping pattern per wire shape, a class pattern per
dataclass, an if guard where a value decides, and case _ last as the fallback. The first case
that succeeds wins, in source order, so put the specific cases first. Use dotted names such as
Status.FAILED for constants, because a bare name is a capture that matches everything. Test
every row of a message table against the handler it should reach.
You will need
Python 3.10 or later, where the match statement of PEP 634
arrived.1 Verified 2026-09-24 against Python 3.11.15, with no packages installed. pytest is
not on the runner that re-runs these commands, so the table test runs under the standard library
with one case per row. The same table under
pytest.mark.parametrize sits beside
it in the sample directory.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| dict of handlers | One key decides, and other modules register handlers without editing the router | One dimension, so a second property such as status is a branch inside the handler, not in the router | Two properties decide the handler, or the message is an object with no type key |
| functools.singledispatch | Messages are dataclass instances and the class alone decides | Dispatch on the type of the first argument only, so every dict lands on one function whatever it holds | Messages arrive as dict values from the wire |
| match statement (PEP 634) | Shape, values, and guards decide, and the router reads like the specification | Closed: a case is added by editing the statement, and exhaustiveness is your job, not the interpreter’s | Other modules must add handlers at runtime |
Destructuring buys readability and costs open extension. A match reads the message’s shape,
binds the values it needs, and puts the guard beside the pattern, and every case lives in one
statement that only its module can edit. A dict of handlers is open to registration from
anywhere and sees one key. singledispatch sees the class and nothing inside it, which for a
dict from the wire is nothing at all.
Write the cases in the order they should win
The whole router is one statement. Read it top to bottom, because that is the order the interpreter reads it.
def route(message) -> str:
match message:
case {"type": "order:create", "total": int(total)} if total >= LARGE_ORDER:
return "review_large_order"
case {"type": "order:create", "items": [_, *_]}:
return "create_order"
case {"type": "order:create"}:
return "reject_empty_order"
case {"type": "payment", "status": Status.FAILED}:
return "retry_payment"
case {"type": "payment", "status": Status.OK}:
return "record_payment"
case OrderCreated(total=total) if total >= LARGE_ORDER:
return "review_large_order"
case OrderCreated():
return "create_order"
case PaymentCaptured(amount=0):
return "ignore_zero_capture"
case PaymentCaptured():
return "record_payment"
case _:
return "dead_letter"
A mapping pattern
matches a dict that has the keys it names and ignores every other key. int(total) binds
total only when the value is an int, and the guard runs after the binding, so a string total
falls through rather than raising. [_, *_] is a sequence pattern for one or more items, which
is how an empty order is told apart from a real one without a length check.
The class patterns do the same for objects. OrderCreated(total=total) matches an instance and
binds one attribute; PaymentCaptured(amount=0) compares an attribute with a literal. case _
is the wildcard, and the reference is strict about it: a match may have at most one
irrefutable case,
and it must be last.
Order carries meaning. The review case sits before the create case, so a large order with no items is reviewed rather than rejected, and the table test pins that.
Compare the three routers on one table
The demo runs the same eleven messages through all three routers and marks every answer that differs from the expected handler.
python3 demo.py
message expected match dict singledispatch
large order, dict review_large_order review_large_order review_large_order dead_letter (x)
order, dict create_order create_order create_order dead_letter (x)
empty order, dict reject_empty_order reject_empty_order reject_empty_order dead_letter (x)
failed payment, dict retry_payment retry_payment retry_payment dead_letter (x)
ok payment, dict record_payment record_payment record_payment dead_letter (x)
large order, dataclass review_large_order review_large_order dead_letter (x) review_large_order
order, dataclass create_order create_order dead_letter (x) create_order
zero capture, dataclass ignore_zero_capture ignore_zero_capture dead_letter (x) ignore_zero_capture
capture, dataclass record_payment record_payment dead_letter (x) record_payment
unknown type dead_letter dead_letter dead_letter dead_letter
not a message dead_letter dead_letter dead_letter dead_letter
The dict router gets every dict right and every dataclass instance wrong, because the
instance has no type key to look up. The handlers behind it carry the second dimension themselves: the
order:create handler checks the total and the items before it answers, which is the branch the
match version puts in the router.
singledispatch is the mirror image. It gets every dataclass instance right and sends every
dict to one function, because it reads the class of its first argument and nothing else.
@route.register
def _(message: dict) -> str:
# Every dict arrives here, whatever its "type" key says. The contents are
# invisible to the dispatcher, so a wire message needs a second router.
return "dead_letter"
Neither is wrong. Each answers one question about a message, and the match is the one that
can ask both.
Check it worked
One case per row of the table, named after the row, plus three cases for the failures this page is about.
def row(label, message, expected):
"""One case per table row, named after the row, so a failure names the message."""
def check():
assert route(message) == expected, f"{label}: got {route(message)!r}"
check.__name__ = label
return check
for label, message, expected in TABLE:
case(row(label, message, expected))
python3 test_dispatch.py
ok large order, dict
ok order, dict
ok empty order, dict
ok failed payment, dict
ok ok payment, dict
ok large order, dataclass
ok order, dataclass
ok zero capture, dataclass
ok capture, dataclass
ok unknown type
ok not a message
ok an_unknown_type_reaches_the_fallback
ok the_first_matching_case_wins_in_source_order
ok a_bare_name_before_another_case_does_not_compile
14 cases, 0 failures
14 cases, 0 failures is the line to look for. The thirteenth case sends a large order with no
items and asserts it is reviewed, which is the source-order rule under test. Under pytest the
same table is three lines, and ids puts the row label in the test name.
@pytest.mark.parametrize(("label", "message", "expected"), TABLE, ids=[row[0] for row in TABLE])
def test_route(label, message, expected):
assert route(message) == expected
Add a row for every message shape the worker accepts, and add the row before the case. A table that grows only when a bug is found is a changelog, not a test.
Watch a bare name capture everything
case status: does not compare the subject with a variable named status. It is a
capture pattern: it
matches anything and binds the name. The pitfall script compiles the two ways that goes wrong and
runs the one that compiles.
python3 pitfall.py
a bare name before another case
SyntaxError: name capture 'status' makes remaining patterns unreachable
a bare name as the last case
{'status': 'ok'} -> record_payment
{'status': 'failed'} -> captured 'failed'
{'status': None} -> captured None
a dotted name, which is a value pattern
{'status': 'ok'} -> record_payment
{'status': 'failed'} -> retry_payment
{'status': None} -> dead_letter
The first form is refused at compile time, with a message that says what happened. The second
form compiles, because a capture is allowed to be the last case, and it then does what a capture
does. failed and None are both captured, and the fallback the author meant to write is gone.
The fix is a dot. Status.FAILED is a
value pattern, looked
up by name and compared with ==, which is why the constants in the sample live on a class. A
literal works the same way; a bare module-level constant does not, however loudly it is
capitalized.
When it goes wrong
A case never runs. An earlier, broader case matches first, because the first case that succeeds wins in source order. Move the specific case up, and add a table row for the message it should catch.
TypeError: OrderCreated() accepts 0 positional sub-patterns (1 given). The class has no
__match_args__, so a positional class pattern has nothing to bind. Use keyword patterns,
OrderCreated(total=total), or make the class a dataclass, which generates __match_args__
for you.2
A mapping pattern accepts a message with keys you meant to refuse. Mapping patterns ignore extra
keys by design. Bind the rest with **rest and add a guard, if not rest, on the cases that
must be exact.
When not to do this
Do not use match when other modules must add handlers at runtime. A match is one statement
in one module, and a plugin cannot append a case to it. A dict of handlers with a register
decorator is open, and it is the better tool when other packages bring their own message types.
Do not reach for singledispatch to route wire messages. It reads the class of the first
argument and nothing inside it, and every message from the wire is a dict.
Do not write a bare name where you mean a constant. case status: binds, case Status.FAILED:
compares, and the interpreter only warns you when the capture is not the last case.
Do not treat a match as exhaustive because it compiled. Python checks nothing about coverage,
so keep case _ at the bottom and keep the table. On 3.11 or later, a fallback that calls
assert_never lets
mypy check a
match over an Enum or a union, which is the closest Python comes to a compiler doing the job.3
Related how-tos
Last verified
Verified 2026-09-24 against Python 3.11.15. Every output block is what the preceding command
printed. pytest is not installed on the runner that re-runs these commands, so
test_dispatch.py uses the standard library. test_dispatch_pytest.py was run once against
pytest 9.1.1 from a scratch install and passed its eleven tests.
Footnotes
-
Python considered a switch statement in 2006 and turned it down. PEP 3103, A Switch/Case Statement, is filed as rejected, and the 3.10 release notes record structural pattern matching arriving fifteen years later under a different name and with considerably more ambition. The statement that was refused compared a value with constants. The one that was accepted reads shapes and binds names, which is the reason a bare name in a case is a variable and not a constant. ↩︎ Back to text
-
The
dataclassdecorator takes amatch_argsparameter, true by default, and generates__match_args__from the fields in declaration order. That tuple is what letscase OrderCreated(order_id, total):bind two names by position. The decorator gained a parameter so that a statement elsewhere in the language could work on its output, which is a small example of one feature paying for another. ↩︎ Back to text -
The proposal was one document before it was three. PEP 622 is filed as superseded by 634, and its successors split the work into a specification, PEP 634, a motivation and rationale, PEP 635, and a tutorial, PEP 636. A language feature that needs a separate document to say why it exists is not unusual. One that ships its own tutorial as a standards document is rarer, and the tutorial is the one of the three that gets read. ↩︎ Back to text