Structural Pattern Matching in Python: A Comprehensive Guide
Structural pattern matching is a feature added in Python 3.10 that makes it easier to work with different types of data and control the flow of your code.
Instead of writing long chains of if
, elif
, and else
statements, you can write cleaner and easier-to-read code using pattern matching. It works with many data types—like lists, dictionaries, and even custom classes—so it’s helpful in many situations.
This article teaches you how to use pattern matching in your Python projects. We’ll walk through the basics, show typical use cases, and share tips for writing clean, effective code.
Prerequisites
Before you start, make sure you're using Python 3.13 or later. You can check your current version by running:
python3 --version
Python 3.13.2
If you're using an older version, you'll need to download and install a newer one to use structural pattern matching.
Getting started with pattern matching
To get a feel for how pattern matching works, let’s start with a simple example and compare it to the traditional way of writing control flow in Python.
First, create a new folder for your project and move into it:
mkdir python_pattern_matching && cd python_pattern_matching
Now, create a file called app.py
. This is where you’ll write your code.
Here’s how a basic command processor would look using the if-elif
structure, without pattern matching:
def process_command(command):
if command == "help":
return "Available commands: help, version, exit"
elif command == "version":
return "v1.0.0"
elif command == "exit":
return "Goodbye!"
else:
return f"Unknown command: {command}"
# Test with different commands
print("=== Output ===")
print(process_command("help"))
print(process_command("version"))
print(process_command("unknown"))
The process_command()
function checks the value of command
using a series of if-elif
conditions. Each condition returns a response based on the command. A fallback message is returned if the command doesn't match any known options.
To run the file, enter the following in your terminal:
python3 app.py
The output should be:
=== Output ===
Available commands: help, version, exit
v1.0.0
Unknown command: unknown
This works fine for simple cases, but as the number of commands grows or the logic becomes more complex, the if-elif
chain becomes harder to read and maintain.
With pattern matching, you get a cleaner and more structured way to handle multiple conditions:
def process_command(command):
match command:
case "help":
return "Available commands: help, version, exit"
case "version":
return "v1.0.0"
case "exit":
return "Goodbye!"
case _:
return f"Unknown command: {command}"
# Test with the same commands
print("=== Output ===")
print(process_command("help"))
print(process_command("version"))
print(process_command("unknown"))
In this version, the structure is much clearer:
- The
match
statement checks the value ofcommand
. - Each
case
handles a specific match and runs the corresponding code. case _:
is a wildcard that catches anything that doesn’t match the earlier cases—similar to adefault
clause in a traditional switch statement.
While this example is simple, pattern matching shines when working with more complex data and nested structures, as you'll see in the next sections.
When you run the file, the output will be:
=== Output ===
Available commands: help, version, exit
v1.0.0
Unknown command: unknown
The result is the same, but the code is easier to read and extend.
Pattern matching with different types
Now that you understand the basics, let's see how pattern matching handles different data types. This is where pattern matching really shines compared to traditional if-else chains.
Let's modify our app.py
file to see how pattern matching can elegantly handle different types of inputs:
def describe_data(data):
match data:
case None:
return "Nothing to describe"
case int(n):
return f"An integer: {n}"
case str(s):
return f"A string of length {len(s)}"
case list(l):
return f"A list with {len(l)} items"
case dict(d):
return f"A dictionary with {len(d)} keys"
case _:
return f"Something else: {type(data).__name__}"
# Test with different data types
print("=== Type Matching ===")
print(describe_data(42))
print(describe_data("hello"))
print(describe_data([1, 2, 3]))
print(describe_data({"a": 1, "b": 2}))
print(describe_data(None))
print(describe_data(3.14))
In this code, the describe_data()
function uses pattern matching to respond differently based on the input type. Each case
matches a specific data type:
None
matches a missing or undefined value.int(n)
captures an integer and binds it ton
.str(s)
handles strings and gives their length.list(l)
anddict(d)
handle lists and dictionaries, reporting how many items or keys they have.case _:
is the fallback for anything that doesn't match the earlier patterns (like a float, in this case).
To see it in action, run the file with:
python3 app.py
You'll see:
=== Type Matching ===
An integer: 42
A string of length 5
A list with 3 items
A dictionary with 2 keys
Nothing to describe
Something else: float
Without pattern matching, you'd need to write a series of isinstance()
checks to achieve the same result:
def describe_data_traditional(data):
if data is None:
return "Nothing to describe"
elif isinstance(data, int):
return f"An integer: {data}"
elif isinstance(data, str):
return f"A string of length {len(data)}"
elif isinstance(data, list):
return f"A list with {len(data)} items"
elif isinstance(data, dict):
return f"A dictionary with {len(data)} keys"
else:
return f"Something else: {type(data).__name__}"
The pattern-matching version is more concise and follows a consistent structure that clearly separates the type checking from the value processing.
Adding conditions with guard clauses
You can make your patterns more precise by adding conditions using guard clauses with the if
keyword.
To try this out, replace the contents of app.py
with the following:
def categorize_number(n):
match n:
case int(x) if x < 0:
return "Negative integer"
case 0:
return "Zero"
case int(x) if x % 2 == 0:
return "Positive even integer"
case int(x):
return "Positive odd integer"
case float(x) if x.is_integer():
return f"Float {x} that equals an integer"
case float(x):
return f"Float: {x}"
case _:
return "Not a number"
# Test with different numbers
print("\n=== Numbers with Guard Clauses ===")
print(categorize_number(-5))
print(categorize_number(0))
print(categorize_number(6))
print(categorize_number(7))
print(categorize_number(5.0))
print(categorize_number(3.14))
print(categorize_number("42"))
This example uses guard clauses in pattern matching to handle numbers more precisely. Conditions like int(x) if x < 0
match negative integers, while others handle zero, even and odd integers, or floats that represent whole numbers.
The if
keyword adds extra checks to each pattern, making the logic more specific and readable. A final fallback catches anything that doesn’t match, like strings. This approach keeps the code clean and easy to follow, even with varied input.
Running this will show:
=== Numbers with Guard Clauses ===
Negative integer
Zero
Positive even integer
Positive odd integer
Float 5.0 that equals an integer
Float: 3.14
Not a number
This approach keeps related logic grouped together in a clear, readable format that's easy to understand and maintain.
Pattern matching with sequences
Pattern matching shines when working with sequences like lists and tuples. Let's see how it can help us extract and process data from these structures.
Replace the contents in your app.py
file with this example:
def analyze_point(point):
match point:
case (0, 0):
return "Origin point"
case (0, y):
return f"Point on the y-axis at y={y}"
case (x, 0):
return f"Point on the x-axis at x={x}"
case (x, y) if x == y:
return f"Point on the diagonal line at x=y={x}"
case (x, y):
return f"Point at coordinates ({x}, {y})"
case _:
return "Not a valid point"
# Test with various points
print("=== Coordinate Pattern Matching ===")
print(analyze_point((0, 0)))
print(analyze_point((0, 5)))
print(analyze_point((3, 0)))
print(analyze_point((4, 4)))
print(analyze_point((2, 7)))
print(analyze_point("not a point"))
The analyze_point()
function identifies different types of points on a coordinate system:
- Points at the origin (0, 0)
- Points on the x-axis or y-axis
- Points on the diagonal where x equals y
- Any other valid point with x and y coordinates
When you run this code, you'll see:
=== Coordinate Pattern Matching ===
Origin point
Point on the y-axis at y=5
Point on the x-axis at x=3
Point on the diagonal line at x=y=4
Point at coordinates (2, 7)
Not a valid point
Notice how pattern matching makes it easy to check for specific values in a tuple (like (0, 0)
), capture variables from specific positions (like (0, y)
), and even combine patterns with conditions (like (x, y) if x == y
).
Working with lists
Lists are another sequence type where pattern matching is beneficial. Let's see how we can analyze lists of different lengths and structures:
def describe_list(items):
match items:
case []:
return "Empty list"
case [x]:
return f"Single-item list containing: {x}"
case [x, y]:
return f"Two-item list containing: {x} and {y}"
case [x, y, z, *rest] if not rest:
return f"Three-item list: {x}, {y}, and {z}"
case [first, *middle, last]:
return f"List with {len(middle) + 2} items, starting with {first} and ending with {last}"
case _:
return "Not a list"
# Test with various lists
print("\n=== List Pattern Matching ===")
print(describe_list([]))
print(describe_list([42]))
print(describe_list(["apple", "orange"]))
print(describe_list([1, 2, 3]))
print(describe_list([1, 2, 3, 4, 5]))
print(describe_list((1, 2, 3))) # Tuple instead of list
The describe_list()
function handles different list shapes. It detects empty lists, single-item and two-item lists, and lists with three specific elements.
It also uses rest patterns like *rest
to match longer lists, and head-tail patterns like [first, *middle, last]
to access the first and last items directly.
A guard clause ensures that one of the patterns only matches exactly three items.
Without pattern matching, this would require multiple length checks and indexing, making the logic more verbose and harder to follow
Running this code produces:
=== List Pattern Matching ===
Empty list
Single-item list containing: 42
Two-item list containing: apple and orange
Three-item list: 1, 2, and 3
List with 5 items, starting with 1 and ending with 5
Three-item list: 1, 2, and 3
Pattern matching brings the focus back to the structure of your data, making your code more declarative and easier to understand.
Pattern matching with dictionaries
Dictionaries are a fundamental data structure in Python, and pattern matching makes it much easier to work with them, especially when they have nested or complex structures.
Let's explore how pattern matching handles dictionaries. Replace the contents in your app.py
file:
def process_user(user):
match user:
case {"name": name, "age": age} if age < 18:
return f"{name} is a minor"
case {"name": name, "age": age, "role": "admin"}:
return f"{name} is an admin aged {age}"
case {"name": name, "age": age}:
return f"{name} is {age} years old"
case {"name": name}:
return f"{name} has no age specified"
case _:
return "Invalid user data"
# Test with different user dictionaries
print("=== Dictionary Pattern Matching ===")
print(process_user({"name": "Alice", "age": 15}))
print(process_user({"name": "Bob", "age": 42, "role": "admin"}))
print(process_user({"name": "Charlie", "age": 30}))
print(process_user({"name": "Dave"}))
print(process_user({"user_id": 123}))
The process_user()
function demonstrates how pattern matching with dictionaries allows you to check for specific keys, automatically bind values to variables like name
and age
, and apply additional conditions using guard clauses (e.g. if age < 18
).
Since patterns are matched in order, the most specific cases should come first to ensure the correct match is applied. When you run the code, the output will be:
When you run this code, you'll see:
=== Dictionary Pattern Matching ===
Alice is a minor
Bob is an admin aged 42
Charlie is 30 years old
Dave has no age specified
Invalid user data
The same logic without pattern matching would be significantly more verbose and harder to read:
def process_user_traditional(user):
if not isinstance(user, dict):
return "Invalid user data"
if "name" not in user:
return "Invalid user data"
name = user["name"]
if "age" in user:
age = user["age"]
if age < 18:
return f"{name} is a minor"
elif "role" in user and user["role"] == "admin":
return f"{name} is an admin aged {age}"
else:
return f"{name} is {age} years old"
else:
return f"{name} has no age specified"
This approach requires explicit key checking and deep nesting, making it harder to understand the different cases at a glance.
Final thoughts
Pattern matching brings clarity and structure to Python code, making it easier to handle complex conditions without relying on lengthy if-elif
chains. It improves readability, simplifies value extraction, and handles different data shapes—like basic types and dictionaries—more elegantly.
For a deeper dive, check out PEP 634 or the official Python documentation.
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github