Back to Scaling Python Applications guides

Structural Pattern Matching in Python: A Comprehensive Guide

Stanley Ulili
Updated on March 27, 2025

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
Output
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:

app.py
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
=== 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 of command.
  • 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 a default 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
=== 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:

app.py
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 to n.
  • str(s) handles strings and gives their length.
  • list(l) and dict(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:

Output
=== 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:

app.py
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:

Output
=== 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:

app.py
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:

Output
=== 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:

app.py
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:

Output
=== 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:

app.py
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:

Output
=== 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.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
Get Started with Job Scheduling in Python
Learn how to create and monitor Python scheduled tasks in a production environment
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

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
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
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.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github