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

Source: https://voxgig.com/howto/dispatch-messages-with-structural-pattern-matching-in-python

- Audience: api-producer
- Level: intermediate
- Languages: python
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://peps.python.org/pep-0634/)
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`](https://docs.pytest.org/en/stable/how-to/parametrize.html) 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](https://docs.python.org/3/builtins/stdtypes.html#dict) | 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](https://docs.python.org/3/library/functools.html#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)](https://peps.python.org/pep-0634/) | 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.

```python title="dispatch_match.py"
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](https://docs.python.org/3/reference/compound_stmts.html#mapping-patterns)
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](https://docs.python.org/3/reference/compound_stmts.html#irrefutable-case-blocks),
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.

```bash
python3 demo.py
```

```text output
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.

```python title="dispatch_single.py"
@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.

```python title="test_dispatch.py"
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))
```

```bash
python3 test_dispatch.py
```

```text output
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.

```python title="test_dispatch_pytest.py"
@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](https://docs.python.org/3/reference/compound_stmts.html#capture-patterns): it
matches anything and binds the name. The pitfall script compiles the two ways that goes wrong and
runs the one that compiles.

```bash
python3 pitfall.py
```

```text output
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](https://docs.python.org/3/reference/compound_stmts.html#value-patterns), 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`](https://docs.python.org/3/library/typing.html#typing.assert_never) lets
[`mypy`](https://mypy.readthedocs.io/en/stable/literal_types.html#exhaustiveness-checking) 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

- [Dispatch messages to handlers with a Map keyed by type](/howto/dispatch-messages-with-a-handler-map)

- [Limit concurrency in a message worker](/howto/limit-concurrency-in-a-message-worker)

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

[^1]: Python considered a switch statement in 2006 and turned it down.
[PEP 3103](https://peps.python.org/pep-3103/), A Switch/Case Statement, is filed as rejected, and
the [3.10 release notes](https://docs.python.org/3/whatsnew/3.10.html) 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.

[^2]: The [`dataclass`](https://docs.python.org/3/library/dataclasses.html) 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.

[^3]: The proposal was one document before it was three. [PEP 622](https://peps.python.org/pep-0622/)
is filed as superseded by 634, and its successors split the work into a specification,
[PEP 634](https://peps.python.org/pep-0634/), a motivation and rationale,
[PEP 635](https://peps.python.org/pep-0635/), and a tutorial,
[PEP 636](https://peps.python.org/pep-0636/). 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.