Skip to content

← Modern Syntax and Modernisation step 15 of 18

Medium Primitives

Sequence and mapping patterns: the two rules everyone gets wrong

Two rules about structural patterns cause almost all of the confusion, and one of them is a security-relevant misconception.

Rule 1 — str and bytes are not sequences here

match payload:
    case [a, b, c]:
        ...

Given payload = "abc" this does not match. str, bytes and bytearray are explicitly excluded from sequence patterns (PEP 634), precisely because “iterate a string and you get characters” is the source of a thousand bugs. You do not have to write a defensive case str(): ... first — although you may want one anyway, to route strings deliberately.

Anything else registered as a collections.abc.Sequence does match: list, tuple, range, memoryview, and your own classes if you register them.

Rule 2 — mapping patterns are partial, and there is no way to make them total

match payload:
    case {"type": "user"}:
        create_user(payload)

This matches {"type": "user"}. It also matches {"type": "user", "is_admin": True, "id": 0, ...forty more keys}. Extra keys are ignored, always, and the language provides no syntax to demand an exact key set. case {}: does not mean “empty mapping” — it means “any mapping at all”, the mapping equivalent of _.

The consequence is worth saying plainly: match is not schema validation. It is a dispatch mechanism. If your requirement is “reject payloads with unexpected keys”, you need a validator — a TypedDict with closed=True (3.15), a pydantic model with extra="forbid", or an explicit key-set check. Reaching for match there gives you a shape check that silently admits everything you were trying to exclude.

A **rest capture collects the unmatched keys (case {"type": t, **rest}:), which lets you inspect the extras — but a bare **_ is a SyntaxError, and capturing rest still does not make the pattern refuse them.

What you are building

def describe(cmd: object) -> str
def solve(commands: list[object]) -> list[str]

describe classifies one command; solve maps it over the list. In order:

pattern result
empty sequence "empty"
one-element sequence whose element is a str v "verb:{v}"
sequence whose first element is a str v, with n more "verb:{v}+{n}"
any other sequence of length n "seq:{n}"
a str s "text:{s}"
bytes or bytearray "binary"
mapping with "type": "batch" and a sequence "items" of length n "batch:{n}"
mapping with "type": "user" and an int "id" u "user:{u}"
mapping with a str "type" k "kind:{k}"
any other mapping "mapping"
anything else "scalar"

Write the sequence cases before the str and bytes cases. The tests include a three-character string precisely so that a solution which “defends” by ordering the str case first learns nothing — and one which relies on the exclusion rule passes.

Related

The exactness that mapping patterns cannot express is exactly what a closed TypedDict gives you, and what extra="forbid" gives you in a validation library. Pattern matching and validation are different jobs; use both.