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.
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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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;wordsgets 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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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": Trueis a literal pattern, compared by identity, so it fits JSON’strueand nothing else.int(x)andint(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;restis 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,**restcollects them, and{}fits every dictionary.Class(attr=pattern)checksisinstance()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 namecaptures 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"}givesAsha: Hello{"type": "image", "from": "Ravi", "size": [640, 480]}givesRavi sent a 640x480 image; the size is a list of exactly two whole numbers, both greater than 0{"type": "join", "user": "Mei"}givesMei joined{"type": "leave", "user": "Mei", "reason": "timeout"}givesMei left (timeout){"type": "leave", "user": "Mei"}, without a reason, givesMei left{"type": "reaction", "from": "Asha", "emoji": "👍", "to": 42}givesAsha 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
References
- The Python Language Reference: the match statement (Python Software Foundation)
- The Python Tutorial: match statements (Python Software Foundation)
- PEP 634: Structural Pattern Matching: Specification (Python Software Foundation)
- PEP 636: Structural Pattern Matching: Tutorial (Python Software Foundation)
- Data model: customizing positional arguments in class pattern matching (Python Software Foundation)
- dataclasses: the match_args parameter (Python Software Foundation)
- json: how JSON values become Python values (Python Software Foundation)
- Built-in types: bool is a subclass of int (Python Software Foundation)
- What's New In Python 3.14: improved error messages (Python Software Foundation)
- What's New In Python 3.15: other language changes (Python Software Foundation)
- RFC 9110: HTTP Semantics, section 4.2.2 (the https scheme) (Internet Engineering Task Force (IETF))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress