Dev Synthesis Lesson · Polyglot-Dev / Python · Sprint Track Python · Day 23

Python's Structural Pattern Matching the Subject, Not the Keys

An if-chain asks whether a field is present. A match asks what the thing is.

Filed: 2026-08-14 · Fajr anchor · trio #89
Sprint track: Python — day 23, eighth visit
Dev slot: language depth, lap 2 — sequence and mapping patterns (Ramalho Ch.2 / Ch.3)
Paired Ops lesson: Python Ops — Subprocess and SIGTERM: the Child That Never Saw the Log Context
Paired Cert lesson: AWS SAP — Transit Gateway, RAM Shares, and the Attachment That Is Not a Peering
Grounding: Ramalho, Fluent Python 2ed, Ch.2 p.38 · Ch.3 pp.81-83 · Lattice Layer 2
Length: ~2,460 words
The subject, not the keys
Put the object in the subject position. The case that matches is the one whose shape the subject already has. Stop hunting fields to decide what the thing is.
Sequences are total. Mappings are partial.
A sequence pattern cares about length. A mapping pattern succeeds with extra keys and fails on a missing one. First match wins: specific above general.
Parse before you match
Bytes are a sequence of integers. Decode JSON on the parent's side of the hop, then match the mapping the parent built.
An if-chain asks whether a field is present. A match asks what the thing is.

An if-chain asks whether a field is present. A match asks what the thing is.

§I — Frame

Today's Ops lesson ends with a payload the parent has to classify. An account id, a child return code, and a body that is either a describe-instances document (Reservations holding a sequence of instances) or an error document (Error holding a Code and a Message). The first version of that classifier is always the same:

if isinstance(body, dict) and "Reservations" in body:
    ...
elif isinstance(body, dict) and body.get("Error", {}).get("Code") == "UnauthorizedOperation":
    ...

Every branch starts by hunting a key. The object under discussion disappears behind the field the author remembered to test. Miss a key, add a default, and a malformed body becomes a quiet None that falls through to the last else.

Ramalho's treatment of match/case in Fluent Python, 2nd ed., opens at the sequence patterns in Chapter 2 (p. 38) and continues at the mapping patterns in Chapter 3 (pp. 81-83). The move is the same in both chapters. You put the object in the subject position. The pattern describes a shape. The case that matches is the one whose shape the subject already has.

Coin it: the subject, not the keys.

§II — Language idiom: two pattern families and one asymmetry

Sequence patterns.

A sequence pattern looks like a list or a tuple. It matches a sequence of that length, or of at least that length if it uses a starred capture. Ramalho's Chapter 2 examples treat the subject as a record whose position is the meaning: first item, second item, the rest. A pattern [lat, lon] does not ask whether the subject has a key named lat. It asks whether the subject is a two-item sequence, and it binds the items.

Length is part of the shape. [x] does not match [x, y]. [x, y, *rest] matches two or more. That is the opposite of how most operators write an if-chain, which treats extra items as harmless. A sequence pattern that should accept a tail has to say so.

The subject can be any actual or virtual sequence. A tuple, a list, a range. A string is a sequence of characters and will match a character pattern if you let it; if the subject is supposed to be a record, convert it before the match, or the first surprising string will bind lat to 'u' and lon to 's'.

Mapping patterns.

A mapping pattern looks like a dict literal. It matches any mapping that already has the required keys. Extra keys do not disqualify the match. Ramalho states this as the contrast with sequences (p. 83): mapping patterns succeed on partial matches. The b1 and b2 book records in his doctest carry a title that no book pattern names, and they still match.

There is no need for **extra unless you want the leftovers as a dict. **_ is forbidden because it would be redundant. **details must be last.

A missing key fails the pattern. Pattern matching uses d.get(key, sentinel), not d[key], so a defaultdict or a mapping that fabricates values on __getitem__ does not invent keys to satisfy a match (p. 83). The subject has to already have the key at the top of the match. That is the whole reason to prefer a mapping pattern over body.get("Error") or {}: get will happily hand you a default, and the default will look like a present field.

The asymmetry.

Sequence patterns are total in length and positional in meaning. Mapping patterns are partial in keys and named in meaning. Used together they describe the documents APIs actually return: a mapping with a nested sequence, or a sequence of mappings.

Ramalho's get_creators (Example 3-2, p. 81) is the canonical nesting. A book at API version 2 carries authors as a sequence; a book at API version 1 carries author as a single name; a book with neither is invalid; a movie carries director. The subject is record. Each case names a shape. The function never asks if "authors" in record first.

Guards (case ... if cond:) refine a shape they already matched. They do not replace the shape. A guard that re-does the key test is an if-chain wearing a case costume.

The wildcard case _: is the last case or it is a bug. Everything after it is dead. An unmarked _ in a nested position is a "present and ignored," which is how a mapping pattern says "this key must exist, I do not care what it is."

§III — Code: classify the child the Ops lesson spawned

The parent from today's Ops lesson receives one result per account. The honest type is a small mapping the parent built, wrapping whatever the child printed.

def classify(result: dict) -> dict:
    match result:
        case {"account": account, "rc": 0, "body": {"Reservations": [*reservations]}}:
            instances = [
                inst["InstanceId"]
                for res in reservations
                for inst in res.get("Instances", [])
                if "InstanceId" in inst
            ]
            return {"kind": "ok", "account": account, "instances": instances}

        case {"account": account, "rc": 0, "body": {"Reservations": []}}:
            return {"kind": "empty", "account": account}

        case {"account": account, "rc": 255, "body": {"Error": {"Code": code, "Message": msg}}}:
            return {"kind": "aws_error", "account": account, "code": code, "message": msg}

        case {"account": account, "rc": 255, "body": {"Error": {"Code": "UnauthorizedOperation"}}}:
            return {"kind": "denied", "account": account}

        case {"account": account, "rc": rc, "body": _} if rc != 0:
            return {"kind": "child_failed", "account": account, "rc": rc}

        case {"account": account, "body": None}:
            return {"kind": "no_body", "account": account}

        case _:
            raise ValueError(f"unclassifiable child result: {result!r}")

Read the order. The denied case as written above never runs, because the more general aws_error case already matched any Error with a Code and a Message. First match wins. Put the more specific shape above the more general one, or the specific shape is decoration.

Corrected:

def classify(result: dict) -> dict:
    match result:
        case {"account": account, "rc": 0, "body": {"Reservations": []}}:
            return {"kind": "empty", "account": account}

        case {"account": account, "rc": 0, "body": {"Reservations": [*reservations]}}:
            instances = [
                inst["InstanceId"]
                for res in reservations
                for inst in res.get("Instances", [])
                if isinstance(inst, dict) and "InstanceId" in inst
            ]
            return {"kind": "ok", "account": account, "instances": instances}

        case {"account": account, "rc": 255, "body": {
            "Error": {"Code": "UnauthorizedOperation", "Message": msg}
        }}:
            return {"kind": "denied", "account": account, "message": msg}

        case {"account": account, "rc": 255, "body": {
            "Error": {"Code": code, "Message": msg}
        }}:
            return {"kind": "aws_error", "account": account, "code": code, "message": msg}

        case {"account": account, "rc": rc, "body": _} if rc != 0:
            return {"kind": "child_failed", "account": account, "rc": rc}

        case {"account": account, "body": None}:
            return {"kind": "no_body", "account": account}

        case _:
            raise ValueError(f"unclassifiable child result: {result!r}")

The empty-reservations case sits above the starred capture because [] matches [*reservations] with reservations == []. If you want empty to be its own kind, you have to say so first. That is the sequence-pattern length rule wearing a mapping coat: the nested sequence has a length, and the more precise length has to go first.

The UnauthorizedOperation case sits above the generic error case for the same reason. Both are mapping patterns. Both require Error.Code and Error.Message. The literal 'UnauthorizedOperation' is the more specific shape.

What the if-chain cannot say in one place, the subject says in the case label. rc is 255 and the body is an error mapping and the code is a denial. The alternative is four get calls and a default that turns a missing Message into None, which then prints as a denial with no reason.

Parse the child's stdout before the match, in the parent, on the parent's side of the hop:

def parse_body(out: bytes):
    if not out:
        return None
    try:
        return json.loads(out)
    except json.JSONDecodeError:
        return {"raw": out[:400].decode("utf-8", "replace")}

A decode failure becomes a mapping with a raw key, which no success or error case matches, so it falls to child_failed or to _ depending on rc. The match never sees bytes. Bytes are a sequence of integers and will match a sequence pattern you did not mean.

The as pattern names a piece you already matched, so a later line can log the whole mapping without rebuilding it. case {"account": account, "body": body} as result: binds account, body, and result. Use it when the case will emit the original payload and a derived field. Do not use it as a way to go back to key-hunting inside the case body.

OR patterns (case A | B:) share one body across two shapes. They are right when the two shapes deserve the same kind. They are wrong when the body then opens an if to tell the shapes apart; that if is the case split you refused to write. Two cases with two kinds stay two cases.

A sequence of already-classified results is itself a subject. After the parent has mapped classify across the sweep, a short match on the list answers the question the 08-02 exit code has to compute:

def verdict(kinds: list[str]) -> int:
    match kinds:
        case []:
            return 2
        case [*_] if "denied" in kinds and all(k in {"ok", "empty", "denied"} for k in kinds):
            return 0
        case [*_] if any(k in {"aws_error", "child_failed", "no_body"} for k in kinds):
            return 1
        case _:
            return 0

The empty sweep is a configuration failure (exit 2, the 08-05 refusing-startup code). A sweep that only saw denials and successes completed; the denials belong in the log, not in the supervisor alarm, because an account the role cannot read is a grant problem and not a child-process problem. Any aws_error or child_failed is a 1. The guards here are honest: the subject is a list of strings, and the distinction is membership, which is not a shape.

§IV — Connection to today's Ops lesson

The Ops lesson wraps the child's output so the run_id stays on the parent's handler. This lesson is the next line of that wrap: once the payload is a mapping the parent built, classify it as a subject.

The hop drops the log context. The if-chain drops the shape. Both recover the same way. Do the work on the parent's side, in the parent's dialect, against a form you named.

A mapping pattern that requires account will not match a result the parent forgot to stamp. That is a feature. The 08-02 run_id filter made the same demand of every log line. The case label is the schema the hop owes the reader.

Guards belong to the leftover cases. if rc != 0 is a guard because the return code is a number, not a shape. if "Error" in body is not a guard; it is the mapping pattern you refused to write.

§V — Prior-lesson reach

Friday's attribute-protocol lesson (08-08) argued that Python's dunder methods are a contract the language calls on your behalf, and that knowing when the call happens is most of the skill. match is the same kind of contract. The interpreter calls __match_args__ on class patterns and get on mapping patterns. You do not call them. You write the shape and the language walks it.

The 08-05 performance lesson closed on measuring before choosing a concurrency model. Pattern matching is not a performance story. It is a classification story. The cost of a match over a handful of cases is lost in the STS round trip the Ops lesson is waiting on. Do not rewrite a match as an if-chain to save a lookup you have not profiled.

The 07-30 packaging lesson made __name__ the module's address. A classifier this small belongs next to the lease, in the same module the parent already owns, not in a plugin. Entry points are for tools other people call. classify is for the payload you just built.

§VI — Closing

Put the object in the subject position. Write the specific shape above the general one. Treat extra mapping keys as normal and extra sequence items as a different shape. Parse bytes before you match. Raise on _ when silence would invent a kind.

Open the last classifier you wrote against an AWS or HTTP JSON body. If the first token of every branch is if and a key name, the subject is still waiting.

Related

🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-14 · Fajr anchor · sprint track Python day 23 · eighth Python visit · trio #89

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-14 · Fajr anchor · sprint track Python day 23 · eighth Python visit · trio #89
Dev slot · LEO-LESSON-2026-08-14-dev · /rod-audited · dual-corpus check clean · Layer 2