How-to › Run message-based services

How to dispatch messages with pattern matching in Python#

Route messages in a Python worker with the match statement, using mapping and class patterns, guards, and a fallback, tested over a table of messages.

Audience
API producer
Level
intermediate
Topic
Route messages by pattern
Languages
Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
dict of handlersOne key decides, and other modules register handlers without editing the routerOne dimension, so a second property such as status is a branch inside the handler, not in the routerTwo properties decide the handler, or the message is an object with no type key
functools.singledispatchMessages are dataclass instances and the class alone decidesDispatch on the type of the first argument only, so every dict lands on one function whatever it holdsMessages arrive as dict values from the wire
match statement (PEP 634)Shape, values, and guards decide, and the router reads like the specificationClosed: a case is added by editing the statement, and exhaustiveness is your job, not the interpreter’sOther 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

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

  1. 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

  2. The dataclass decorator takes a match_args parameter, true by default, and generates __match_args__ from the fields in declaration order. That tuple is what lets case 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.