DEV Community

Cover image for Inside Python's match Statement: The unleashed power of Structural Pattern Matching Syntax
Jafar Khakpour
Jafar Khakpour

Posted on

Inside Python's match Statement: The unleashed power of Structural Pattern Matching Syntax

When Python 3.10 introduced structural pattern matching (match/case syntax), there was an excitement and confusion in the community. In first days, I was one of those confused people (confused of other people's excitement). I was looking at match/case in Python as something similar to switch/case in other languages: a glorified if-elif-else chain. But soon I realized it as a much more powerful feature than simple switch/case.

Now that we have reached a point where all minimum Python versions with active support is 3.10, I thought I can give it a try to use match/case in my codes. But as soon as I started using this syntax, I started realising it has a much deeper links to the language syntax than I thought. In this post, I want to tell you about these findings.

Literal patterns – the first taste

Let’s start with a trivial match on a constant values:

x = 1

match x:
    case 1:
        print("one")
Enter fullscreen mode Exit fullscreen mode

This is equivalent to if x == 1: ... syntax. Easy!

And if we want to execute same code block for multiple values, or have a default block:

x = 1
match x:
    case 1:
        print("one")
    case 2 | 3:
        print("two or three")
    case _:
        print("other")
Enter fullscreen mode Exit fullscreen mode

The _ symbol is part of syntax (some kind of wildcard for Any Values), but I can rewrite the above code like this:

x = 1

match x:
    case 1:
        print("one")
    case 2 | 3:
        print("two or three")
    case n:
        print(f"other: {n}")
Enter fullscreen mode Exit fullscreen mode

Here n is just a simple variable which matches the condition as "it possible to do n <- x? if yes, then set n = x, and execute the code block".

So far, so good. But, this still is not much more than Python's if/elif/else syntax. Python's match/case will shine when you use it for more complex objects. For example you can use these patterns in match expression when working with tuples, lists and dictioanries:

event = {
    "type": "purchase",
    "user": 42,
    "amount": 250,
    "currency": "USD",
    "items_count": 5,
},

match event:
    # Login or signup events
    case {
        "type": "login" | "signup",    # Match any of "login" or "signup" strings
        "user": user_id,
        "ip": ip,
    } as evt:     # get a variable pointing to the event variable
        print(f"User {user_id} login: {evt}", user_id, evt)

    # Failed authentication from external IPs
    case {
        "type": "login_failed",
        "ip": ip,
        "error": {"code": code, "message": message} as error    # you can also use `as` to capture a subpattern
    } if not ip.startswith("10."):
        print(f"Suspicious login from IP address {ip}. Info: {rest!r}")

    # Premium purchases
    case {
        "type": "purchase",
        "user": _,    # event must have a key user with any values (_ is a special keyword inside match syntax and you cannot treat it as a variable in case blocks)
        "amount": amount,
        "currency": "USD",
        **rest,
    } if amount >= 100:    # Define comlex conditions in if caluse
        print(f"Large purchase with amount {amount} placed")

    # Normal purchases
    case {
        "type": "purchase",
        "amount": amount,
    }:
        print(f"Normal purchase: {event}")

    case _: ##
        print(f"Ignoring event: {event}")
Enter fullscreen mode Exit fullscreen mode

There are some caveats on using lists and dicys for structural matching:

  • The _ wildcard is a special case in match clause which tells Python to ignore the value, you can't access the value using _ variable in match blocks.
  • Dictionary patterns don't have to list all keys in the object, so {"a": 1, "b": 2} matches pattern {"a": 1}.
  • List patterns matches same size of elements, unless you use a unpacking pattern (either *_ if you don't need these arbitrary length values, or *x to access them in case block).
  • As per above points, there is no **_ pattern in dicts and Python would not ercognize **_ inside patterns.
  • You can't use wildcard variable as dictionary key. This is a wrong pattern: {x: "value"}❌.

Pattern matching on Sets

A Confusing point: You can't use pattern matching capabilities available for dict and list in set! What? Python doesn't support patttern matching for sets? Yes, unfortunately😞. But you can check these conditions:

data = {1,2,3}

match data:
    case set() as s if {1, 2}.issubset(s):    # Check if s is a subclass of type `set` and `{1,2}` is a subset of variable s
        print("Contains 1 and 2")

    case set():     # Just check for s being a set
        print("Some other set")
Enter fullscreen mode Exit fullscreen mode

There is a small point in case set() usage. Python interprets this as if isinstance(data, set). And this is how it handles patterns for custom classes.

Class based structural patterns

So far, we've discussed matching built-in data structures. But structural pattern matching becomes more interesting when we start matching objects created from our own classes.
Consider this example:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(0, 20)

match p:
    case Point(x=0, y=0):
        print("This point is the origin")

    case Point(x=0):
        print(f"Point is on x=0 line")

    case Point(x=x1, y=x2):
        print(f"Point is at ({x1}, {x2})")
Enter fullscreen mode Exit fullscreen mode

The important point here is that Point(x=..., y=...) doesn't create a new Point object. It is a class pattern that asks whether the object is an instance of Point and, if so, checks its attributes.

In other words, case Point(x=x, y=y) roughly means: Does this object instance of Point, and does it have an x and a y attribute whose values I can bind to x1 and x2 variables?

As you can see in case Point(x=0), we have omitted y attribute, because we don't care about the value, even though, Point class cannot be instanciated with only x argument. In fact, Python treats class objects like dict objects.

This is very interesting. But how does that work? Actually, the syntax is a little misleading at the first glance. Python doesn't care about arguments we pass to __init__() of this class. Python interprets case Point(x=0, y=0) as "object p is instance of type Point and point.x == 0 and p.y == 0". Let's check another example:

class Rectangle:
    def __init__(self, w, h):
        self.x = x
        self.y = y

    @property
    def s(self):
        return self.x * self.y

shape = Rectangle(0, 20)

match shape:
    case Rectangle(s=0):    # Rectangle.s is a property!
        print(f"Rectangle size is Zero!")

    case Rectangle(w=w, h=h) if w == h:
        print(f"It's a Square with side size {w}")

    case Rectangle(w=w, h=h):
        print(f"Rectangle with({w=}, {h=})")
Enter fullscreen mode Exit fullscreen mode

Positional Attributes and __match_args__

Because of attributes normally having no order, we define them like named kwargs. Python also allows us to define patterns in positional order, but first we have to define the order using __match_args__:

class User:
    __match_args__ = ("name", "age")

    def __init__(self, name, age):
        self.name = name
        self.age = age

user = User("Jack", 25)

match user:
    case User("Jack", 25):
        print("Hello Jacky.")

    case User():
        print("Do we know each other?")
Enter fullscreen mode Exit fullscreen mode

Dataclasses automatyically define __match_args__

Yes, dataclass types have no logic and they are supposed to eork like a simple container for data, so they can automatically define the order:

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

point = Point(10, 20)
match point:
    case Point(x, y):
        print(f"Point: ({x}, {y})")
Enter fullscreen mode Exit fullscreen mode

The bigger picture

At this point, we can see that Python's patterns aren't limited to simple value comparisons and value checking. We can describe:

  • Mapping structure: case {"type": "login", "user": user_id}:
  • Sequence structure: case [0, x, *_]:
  • Class structure: case User(name=name, age=age):
  • Nested structure:
case User(
    address=Address(city=city)
):
Enter fullscreen mode Exit fullscreen mode
  • Structure + condition: case Circle(radius) if radius > 100:

The common idea is the same in all of them: describe the structure you're interested in, extract the pieces you need, and optionally add a condition.

This fantastic feature has a lot of capabilities. I'm bery keen to see how the community will make use of this feature.

Top comments (0)