Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python Module 3 – Control flow: decisions, loops and pattern matching

Structural matching: lists, dicts and classes

Match lists, tuples, dictionaries and objects by their shape in Python, capture their parts with sequence, mapping, class and as patterns, and handle JSON data.

  • Intermediate
  • 22 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot

What you will learn

  • Match sequences and mappings and capture their parts in one pattern
  • Match class instances with positional and keyword patterns
  • Handle JSON-like data, such as parsed events, with match

Before you start

On this page

The patterns of the last lesson compared a whole value: is it 404, is it "play". Patterns can also describe the shape of a value: a list of two words that starts with "done", a dictionary with a "host" key, a rectangle whose width equals its height. When a pattern like that fits, the parts it names are captured in the same step, so the case block can use them straight away. Python calls this structural pattern matching.

This lesson works through the three structural kinds of pattern, for sequences, mappings and objects, and then combines them to tell apart messages that arrive as JSON.

Sequence patterns: lists and tuples

A pattern written like a list, [a, b], fits a sequence with exactly that many items and matches each item against the pattern in its place. A starred name, *rest, takes any number of items. The words a user types after a program’s name are a list of strings, which makes a to-do program’s command line a good fit:

A command line matched by its shape Python · todo_args.py
def todo(args):
    """Answer the command line of a small to-do program: the words typed after its name."""
    match args:
        case [] | ["help"]:
            return "usage: todo list | todo add TEXT | todo done NUMBER"
        case ["list"]:
            return "showing every task"
        case ["add"]:
            return "add needs the text of the task"
        case ["add", *words]:
            return "added: " + " ".join(words)
        case ["done", number] if number.isdecimal():
            return f"task {number} is done"
        case [command, *rest]:
            return f"not understood: {command} followed by {rest}"
        case _:
            return f"expected a list of words, got {args!r}"


# A real program would call todo(sys.argv[1:]); these are argument lists it could get.
for args in [[], ["list"], ["add", "buy", "milk"], ["add"], ["done", "3"], ["done", "x"], ["remove", "2"], "list"]:
    print(repr(args), "->", todo(args))

Output

[] -> usage: todo list | todo add TEXT | todo done NUMBER
['list'] -> showing every task
['add', 'buy', 'milk'] -> added: buy milk
['add'] -> add needs the text of the task
['done', '3'] -> task 3 is done
['done', 'x'] -> not understood: done followed by ['x']
['remove', '2'] -> not understood: remove followed by ['2']
'list' -> expected a list of words, got 'list'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 todo_args.py

Read the cases from the top:

  • [] fits only an empty list (or tuple), and ["list"] only a list holding exactly the one word "list".
  • ["add", *words] fits a list that starts with "add", however long it is; words gets the remaining items as a list. That list may be empty, which is why ["add"] on its own is caught by the case above it.
  • ["done", number] if number.isdecimal() combines a sequence pattern with a guard, which checks the captured part.
  • [command, *rest] fits any list with at least one item. *_ would accept the same items without keeping them.

The last line passes a string, "list", instead of a list of words, and it reaches the final case _. A string is a sequence of characters in most of Python, but sequence patterns never match strings, bytes or bytearrays, so a string cannot be split into letters by accident. Square brackets and round ones mean the same in a pattern: (x, y) also fits a list, and [x, y] also fits a tuple (one item in round brackets needs a comma, (x,); without it the brackets only group). A sequence pattern can hold one starred name at most, in any position, as in [*rest, last].

Mapping patterns: dictionaries

A pattern written like a dictionary, with fixed keys such as "host", fits a dictionary that has at least those keys, and matches each key’s value against the pattern after its colon. This function finds the server address in a program’s settings, and uses port 443, the default port of https addresses, when none is given:

Settings matched by the keys they have Python · settings.py
def address(settings):
    match settings:
        case {"host": host, "port": port}:
            return f"{host}:{port}"
        case {"host": host}:
            return f"{host}:443"
        case {}:
            return "no host in these settings"
        case _:
            return "the settings are not a dictionary"


for settings in [
    {"host": "example.org", "port": 8080},
    {"host": "example.org", "timeout": 5},
    {"port": 8080},
    {},
    ["example.org", 8080],
]:
    print(settings, "->", address(settings))

Output

{'host': 'example.org', 'port': 8080} -> example.org:8080
{'host': 'example.org', 'timeout': 5} -> example.org:443
{'port': 8080} -> no host in these settings
{} -> no host in these settings
['example.org', 8080] -> the settings are not a dictionary

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 settings.py

The second dictionary has an extra key, "timeout", and still fits {"host": host}: keys that a mapping pattern does not mention are ignored. That is usually what you want for data from outside, which often carries fields your code does not need, but it has a surprising consequence. The pattern {} names no keys, so it fits every dictionary, as {"port": 8080} shows. To catch only an empty one, add a guard: case {} if not settings:.

To keep the keys a pattern did not name, end it with **rest: {"host": host, **rest} puts every other key and its value into a new dictionary called rest. A list is not a mapping, so the last settings reach case _.

Class patterns: objects

A pattern that looks like a call, Circle(radius=r), fits an object of that class (the check is isinstance(), so subclasses fit too) and matches its attributes against the patterns given for them. The keyword form, attribute=pattern, works with any class. The positional form, Rectangle(w, h), needs to know which attribute each position means, and a data class (made with @dataclass) tells it by setting __match_args__ to its fields in order:

Shapes matched by class and attributes Python · shapes.py
import math
from dataclasses import dataclass


@dataclass
class Circle:
    radius: float


@dataclass
class Rectangle:
    width: float
    height: float


@dataclass
class Triangle:
    base: float
    height: float


def describe(shape):
    match shape:
        case Circle(radius=r):
            return f"circle, area {math.pi * r * r:.2f}"
        case Rectangle(w, h) if w == h:
            return f"square, area {w * h}"
        case Rectangle(w, h):
            return f"rectangle, area {w * h}"
        case Triangle(base=b, height=h):
            return f"triangle, area {b * h / 2}"
        case _:
            return f"not a shape: {shape!r}"


print("Rectangle.__match_args__ is", Rectangle.__match_args__)
for shape in [Circle(1.5), Rectangle(3, 4), Rectangle(2, 2), Triangle(6, 2), (3, 4)]:
    print(shape, "->", describe(shape))

Output

Rectangle.__match_args__ is ('width', 'height')
Circle(radius=1.5) -> circle, area 7.07
Rectangle(width=3, height=4) -> rectangle, area 12
Rectangle(width=2, height=2) -> square, area 4
Triangle(base=6, height=2) -> triangle, area 6.0
(3, 4) -> not a shape: (3, 4)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shapes.py

Rectangle(w, h) means Rectangle(width=w, height=h), because Rectangle.__match_args__ is ('width', 'height'). The guard if w == h picks out squares before the general rectangle case. The tuple (3, 4) holds the same numbers as a rectangle, but it is not a Rectangle object, so it fits none of the class patterns.

A class you write yourself has no __match_args__ unless you set it, so positional patterns fail with an error that says how many positions it accepts:

A positional pattern without __match_args__ Python · plain_class.py
class Version:
    def __init__(self, major, minor):
        self.major = major
        self.minor = minor


version = Version(3, 14)

match version:
    case Version(major=3, minor=minor):
        print(f"keyword pattern: Python 3.{minor}")

match version:
    case Version(3, minor):
        print(f"positional pattern: Python 3.{minor}")

Output (exit status 1)

keyword pattern: Python 3.14

Printed as an error (standard error)

Traceback (most recent call last):
  File "plain_class.py", line 14, in <module>
    case Version(3, minor):
         ~~~~~~~^^^^^^^^^^
TypeError: Version() accepts 0 positional sub-patterns (2 given)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 plain_class.py

Write the keyword form, make the class a data class, or give it the attribute yourself: __match_args__ = ("major", "minor") in the class body.

Patterns for built-in types

For a handful of built-in types (bool, bytearray, bytes, dict, float, frozenset, int, list, set, str and tuple), a class pattern with one positional pattern matches the whole value. int(n) fits any integer and captures it as n; str(name) fits any string. That is the way to check the type of a value inside a pattern, before any guard compares it. One surprise: bool is a subclass of int, so int(n) also fits True and False. Where that matters, put a case for bool() above it.

Remember the brackets

A class pattern needs its parentheses even when it captures nothing: case Circle():. Written as case Circle:, it is a bare name, a capture pattern that fits everything and rebinds the name Circle, the trap of the previous lesson.

Nested patterns and as

Patterns nest inside one another to any depth, the way the data does: a sequence pattern can hold class patterns, a mapping pattern can hold sequence patterns. To capture a part that you have also described with a pattern, add as and a name after it: ("Enter" | "Tab") as key checks that the key is one of two strings and keeps the one it was.

The name after as must be a plain name; other targets, such as a tuple of names, are a SyntaxError:

as needs a single name Python · as_target.py
match [3, 4]:
    case [x, y] as (width, height):
        print(width, height)

Output (exit status 1)

Printed as an error (standard error)

  File "as_target.py", line 2
    case [x, y] as (width, height):
                   ^^^^^^^^^^^^^^^
SyntaxError: cannot use tuple as pattern target

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 as_target.py

Version note

The error message above, “cannot use tuple as pattern target”, comes from Python 3.14, which improved the messages for wrong targets after as in imports, except clauses and patterns. Python 3.15 also accepts a unary plus in literal patterns, as in case +1:, to match the unary minus that was always allowed; Python 3.14, which this track runs, rejects it with a SyntaxError.

Putting it together: JSON events

A web page can send what the user did to a server as small JSON objects. json.loads() turns each one into Python values: a JSON object becomes a dict, an array a list, true becomes True, and numbers become int or float. Data like that is exactly what structural patterns are for:

Telling events from JSON apart Python · events.py
import json

# Events as a web page might send them to a server, one JSON value per line.
messages = """
{"type": "click", "x": 120, "y": 45, "button": "left"}
{"type": "key", "key": "Enter"}
{"type": "key", "key": "s", "ctrl": true}
{"type": "scroll", "dy": -3}
{"type": "resize", "size": [1280, 720]}
{"type": "click", "x": "120"}
{"type": "zoom", "level": 2}
["click", 120, 45]
"""


def describe(event):
    match event:
        case {"type": "click", "x": int(x), "y": int(y)}:
            return f"click at ({x}, {y})"
        case {"type": "key", "key": str(key), "ctrl": True}:
            return f"shortcut Ctrl+{key.upper()}"
        case {"type": "key", "key": ("Enter" | "Tab") as key}:
            return f"{key} key"
        case {"type": "scroll", "dy": int(dy)} if dy < 0:
            return f"scroll up {-dy} lines"
        case {"type": "scroll", "dy": int(dy)}:
            return f"scroll down {dy} lines"
        case {"type": "resize", "size": [int(width), int(height)]}:
            return f"window is now {width} x {height}"
        case {"type": str(kind), **rest}:
            return f"cannot handle a {kind!r} event with {rest}"
        case _:
            return f"not an event: {event!r}"


for line in messages.strip().splitlines():
    print(describe(json.loads(line)))

Output

click at (120, 45)
Enter key
shortcut Ctrl+S
scroll up 3 lines
window is now 1280 x 720
cannot handle a 'click' event with {'x': '120'}
cannot handle a 'zoom' event with {'level': 2}
not an event: ['click', 120, 45]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 events.py

A tree of patterns: a mapping pattern checks the type and size keys; the size list must hold two ints, captured as width and height.Mapping pattern:a dict with the keys"type" and "size"?"type": "resize"literal: thevalue mustequal it"size": [ … , … ]sequence:exactly twoitemsint(width)an int,capturedas widthint(height)an int,capturedas height"resize"[1280, 720]1280720

How {"type": "resize", "size": [int(width), int(height)]} takes an event apart

Text description of the diagram

The diagram is a tree that runs from top to bottom, with the values of the event {"type": "resize", "size": [1280, 720]} on its arrows.

  • At the top is the mapping pattern. It fits only a dictionary (or another mapping) that has both keys, "type" and "size"; any other keys are ignored.
  • One branch below it checks the value of "type": the text "resize" must equal the literal pattern "resize".
  • The other branch checks the value of "size": the list [1280, 720] must fit the sequence pattern, a sequence of exactly two items.
  • Below the sequence pattern are two class patterns. int(width) fits the first item, 1280, only if it is an int, and captures it as width; int(height) does the same for the second item, capturing 720 as height.
  • Only when every part fits does the case run, with width and height set.

Each case states the shape of one kind of event, and each line of output shows a rule at work:

  • The first click has a "button" key that no pattern mentions; it is ignored.
  • "ctrl": True is a literal pattern, compared by identity, so it fits JSON’s true and nothing else.
  • int(x) and int(y) check the types as well as capturing, so the click whose "x" is the text "120" and has no "y" fits no click case.
  • {"type": str(kind), **rest} catches every event with a text type that no case above it fitted, the click with the wrong "x" as well as the zoom, and reports what else it carried.
  • The JSON array at the end is a list, not a dictionary, so only case _ fits it.

Order matters here as much as with literals: the cases for a particular kind of event come first, and the general ones last. When nothing fits, the final cases say so instead of letting a malformed event pass as a correct one.

JSON Viewer & Tree Editor Explore a JSON document as a tree to see which keys and nesting your patterns need to describe. Python Online Compiler Run events.py with events of your own, such as a key event without a key.

Key takeaways

  • [a, b] fits a sequence of exactly two items and [first, *rest] one of at least one; rest is always a list. Strings, bytes and bytearrays never match sequence patterns.
  • {"key": pattern} fits a mapping that has at least that key; other keys are ignored, **rest collects them, and {} fits every dictionary.
  • Class(attr=pattern) checks isinstance() and the attributes; the positional form needs __match_args__, which data classes set for you.
  • int(n), str(s) and the other built-in types match the whole value, which checks its type; as name captures a part that a pattern also describes.
  • Put specific shapes before general ones, and end with a case for data that fits none of them.

Exercise

Exercise · Medium · Python

Summarise chat messages with structural patterns

A chat app receives its messages as JSON, and json.loads() turns each one into a dictionary. Write summarise(message), which returns a one-line summary of a message, using a match statement (the tests check that there is one).

These are the six kinds of message. Names, texts, reasons and emoji are strings; a message number is a whole number.

  • {"type": "text", "from": "Asha", "text": "Hello"} gives Asha: Hello
  • {"type": "image", "from": "Ravi", "size": [640, 480]} gives Ravi sent a 640x480 image; the size is a list of exactly two whole numbers, both greater than 0
  • {"type": "join", "user": "Mei"} gives Mei joined
  • {"type": "leave", "user": "Mei", "reason": "timeout"} gives Mei left (timeout)
  • {"type": "leave", "user": "Mei"}, without a reason, gives Mei left
  • {"type": "reaction", "from": "Asha", "emoji": "👍", "to": 42} gives Asha reacted 👍 to message 42

A message may carry other keys too, such as "id" or "pinned": ignore them. Anything else gives unknown message: a missing key, a value of the wrong type ("to": "42" is text, not a number), a size that is not two whole numbers greater than 0, a type the app does not know, or something that is not a dictionary at all.

Starter code · chat.py

def summarise(message):
    """Return a one-line summary of a chat message, or "unknown message"."""
    match message:
        case {"type": "join", "user": str(name)}:
            return f"{name} joined"
        case _:
            return "unknown message"
The sample tests · test_chat.py
import ast
from pathlib import Path

from chat import summarise


def test_text():
    """summarises a text message"""
    assert summarise({"type": "text", "from": "Asha", "text": "Hello"}) == "Asha: Hello"


def test_image():
    """summarises an image with its size"""
    assert summarise({"type": "image", "from": "Ravi", "size": [640, 480]}) == "Ravi sent a 640x480 image"


def test_join_and_leave():
    """summarises people joining and leaving, with or without a reason"""
    assert summarise({"type": "join", "user": "Mei"}) == "Mei joined"
    assert summarise({"type": "leave", "user": "Mei", "reason": "timeout"}) == "Mei left (timeout)"
    assert summarise({"type": "leave", "user": "Mei"}) == "Mei left"


def test_reaction():
    """summarises a reaction to a message"""
    assert summarise({"type": "reaction", "from": "Asha", "emoji": "👍", "to": 42}) == "Asha reacted 👍 to message 42"


def test_extra_keys():
    """ignores keys it does not need"""
    assert summarise({"type": "text", "from": "Asha", "text": "Hi", "id": 7, "pinned": False}) == "Asha: Hi"
    assert summarise({"id": 8, "user": "Mei", "type": "join"}) == "Mei joined"


def test_malformed():
    """treats messages with missing keys or values of the wrong type as unknown"""
    assert summarise({"type": "text", "from": "Asha"}) == "unknown message"
    assert summarise({"type": "text", "from": 5, "text": "Hello"}) == "unknown message"
    assert summarise({"type": "image", "from": "Ravi", "size": [640]}) == "unknown message"
    assert summarise({"type": "image", "from": "Ravi", "size": [640, 0]}) == "unknown message"
    assert summarise({"type": "image", "from": "Ravi", "size": ["640", "480"]}) == "unknown message"
    assert summarise({"type": "reaction", "from": "Asha", "emoji": "👍", "to": "42"}) == "unknown message"
    assert summarise({"type": "poll", "from": "Asha"}) == "unknown message"


def test_not_a_dictionary():
    """treats anything that is not a dictionary as unknown"""
    assert summarise(["text", "Asha", "Hello"]) == "unknown message"
    assert summarise("Hello") == "unknown message"
    assert summarise(None) == "unknown message"


def test_uses_match():
    """uses a match statement"""
    source = Path(__file__).with_name("chat.py").read_text(encoding="utf-8")
    assert any(isinstance(node, ast.Match) for node in ast.walk(ast.parse(source)))
A hint

Write one case for each kind of message, as a mapping pattern with the keys it needs, such as case {"type": "join", "user": str(name)}:. A pattern like str(name) fits only a string and captures it; int(number) does the same for whole numbers, and [int(width), int(height)] fits a list of exactly two of them. Put the leave message with a reason above the one without: a mapping pattern ignores extra keys, so the shorter pattern would also fit the longer message.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 What does this print?

    Read the code, then choose one answer.

    match "go":
        case [first, second]:
            print("two items:", first, second)
        case str(text):
            print("text:", text)
    Show the answer to question 1

    Answer: text: go

    Sequence patterns never match a string (nor bytes or bytearray), although a string is a sequence of characters elsewhere in Python. So the first case fails and str(text) fits, capturing the whole string.

  2. Question 2 of 5 Which of these subjects does case [first, *rest]: match?

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • (1, 2, 3)
    • [1]

    The pattern needs a sequence with at least one item: [1] gives rest == [], and the tuple gives rest == [2, 3] (the starred name always gets a list). An empty list has no first item, a string is never matched by a sequence pattern, and a dictionary is a mapping, not a sequence.

  3. Question 3 of 5 What does plain_class.py print on its standard output before it stops with an error?

    What does this program print? Choose one answer.

    class Version:
        def __init__(self, major, minor):
            self.major = major
            self.minor = minor
    
    
    version = Version(3, 14)
    
    match version:
        case Version(major=3, minor=minor):
            print(f"keyword pattern: Python 3.{minor}")
    
    match version:
        case Version(3, minor):
            print(f"positional pattern: Python 3.{minor}")
    Show the answer to question 3

    Answer: it prints

    keyword pattern: Python 3.14

    Keyword patterns read attributes, which works for any class. The positional pattern Version(3, minor) needs __match_args__ to know which attributes the positions mean; Version has none, so Python raises a TypeError before the second message is printed.

  4. Question 4 of 5 Does case {"type": "ping"}: match the subject {"type": "ping", "id": 7}?

    Choose one answer.

    Show the answer to question 4

    Answer: Yes, because a mapping pattern ignores keys it does not mention

    A mapping pattern checks only the keys it names, and the subject may have any others. To insist on no other keys, capture them with **rest and add a guard such as if not rest.

  5. Question 5 of 5 Which subjects does the pattern case {}: match?

    Choose one answer.

    Show the answer to question 5

    Answer: Any dictionary, with or without keys

    {} names no keys, so every mapping has all the keys it asks for. To match only an empty dictionary, add a guard that tests the subject: for a subject called settings, case {} if not settings:.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.