Pattern matching in Python versus Haskell

Pattern matching was introduced in Python 3.10, sparking debate among developers. Some Python purists view it as an unnecessary and complex feature (e.g., here), while opinions on Reddit range from considering it a source of cognitive overload to an underutilized tool (here).

This post will compare pattern matching in Python and Haskell.

In Haskell, pattern matching is fundamental for deconstructing complex structures. Definitions of functions in Haskell have an implicit match/case pattern. If an argument is given as just a variable, then it matches everything. For example, here is a Fibonacci implementation:

fib n
    | n == 0 = 1
    | n == 1 = 1
    | n = fib (n - 1) + fib (n - 2)

The variable n matches all input values. We then use “guards” to present a series of conditional statements. This is equivalent to a mathematical case statement. Every value on the left, after the “|”, must be boolean.

Alternatively, we can use pattern matching to define the function as three sub-definitions:

fib 0 = 1
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)

Here the Fibonacci definition is broken into three sub-definitions. When n is 0, the function maps to 1, when 1 it maps to 1, in all other cases, it maps to the sum of the prior two Fibonacci values.

In Python, the traditional if-else approach would be:

def fib(n):
    if (n == 0):
        return 1
    elif (n == 1):
        return 1
    else:
        return fib(n - 1) + fib(n - 2)

Using Python’s new pattern matching syntax:

def fib(n):
    match n:
       case 0: return 1
       case 1: return 1
       case _: return fib(n - 1) + fib(n - 2)

This is uglier than the Haskell solution and offers little improvement over the traditional if-else structure.

To Python programmers, pattern matching offers an alternative to if-else constructs. However, since if-else statements typically constitute a small portion of most codebases, pattern matching may have limited impact. Ben Hoyt emphasized this point here.

To Haskell programmers, pattern matching is half the world. Constructors compose data and pattern matching decomposes it. Pattern matching is the primary way we access the structure of data.

In Haskell, we can iterate through lists with pattern matching:

-- handle empty lists
filter _ [] = []
-- handle non-empty lists
filter cond (x:xs) 
  | cond x = x : filter cond xs
  | otherwise = filter cond xs

The equivalent Python expression using pattern matching:

def filterP(cond, xs):
    match xs:
        case []:
            return []
        case [x, *rs] if cond(x): 
            return ([x] + filterP(cond, rs))
        case _:
            return filterP(cond, rs)

filterP(lambda x: x > 3, [1,5,2,7,3,5])

Now, this is horrible code; it is ugly and slow (quadratic time). A more Pythonic approach would be:

def filterL(cond, xs):
    for x in xs:
        if(cond(x)):
            yield x

Or simply:

(x for x in xs if cond(x))

Haskell also has list comprehensions:

[x | x <- xs, cond x]

Pattern matching in Python appears less useful for simple cases where if-else suffices. However, it shows promise with more complex structures. Consider this example adapted from PEP636:

def showPoint(point: Tuple[float, float]) -> None:
    match point:
        case (0, 0):
            print("Origin")
        case (0, y):
            print(f"Y={y}")
        case (x, 0):
            print(f"X={x}")
        case (x, y):
            print(f"X={x}, Y={y}")
        case _:
            raise ValueError("Not a point")

This is very tidy. Without pattern matching it would be:

def showPoint(point: Tuple[float, float]) -> None:
    if point[0] == 0:
        if point[1] == 0:
            print("Origin")
        else:
            print(f"Y={point[1]}")
    else:
        if point[1] == 0:
            print(f"X={point[0]}")
        else:
            print(f"X={point[0]}, Y={point[1]}")

This nested if-else structure is challenging to read and prone to errors, such as accidentally swapping point[0] and point[1].

There are also two possible exceptions that can be raised here. First, if point is not subscriptable, then a TypeError will be raised. For example, if we pass in 42 rather than a tuple. Second, if point has fewer than 2 dimensions, then an IndexError can be raised. There is also the possibility of a silent error such as when a 3D point is submitted.

The implementation with pattern matching is far more explicit. However, tuples are not the only way to represent pairs. Some of the obscurity in the above example could be avoided with a better data representation, such as a Pydantic model:

from pydantic import BaseModel

class Point2D(BaseModel):
    x : float
    y : float

def showPoint(point: Point2D) -> None:
    if point.x == 0:
        if point.y == 0:
            print("Origin")
        else:
            print(f"Y={point.y}")
    else:
        if point.y == 0:
            print(f"X={point.x}")
        else:
            print(f"X={point.x}, Y={point.y}")

This approach avoids indexing issues but still suffers from if-else bloat. The if-else solution requires building a tree of repetitive nested statements. We could refactor to remove this nesting:

def showPoint(point: Point2D) -> None:
    if (point.x == 0 and point.y == 0):
        print("Origin")
    elif (point.x == 0 and point.y == 1)
        print(f"Y={point.y}")
    elif (point.x == 1 and point.y == 0):
        print(f"X={point.x}")
    elif (point.x == 1 and point.y == 1):
        print(f"X={point.x}, Y={point.y}")

In Haskell, this function will be implemented as follows:

showPoint (0, 0) = "Origin"
showPoint (0, y) = "Y=" <> show y
showPoint (x, 0) = "X=" <> show x
showPoint (x, y) = "Y=" <> show y <> "; X=" <> show x

Pattern matching alleviates the need to construct a conditional statement for each match, streamlining code and making it more readable.

Haskell and Python differ significantly in their type systems, which impacts how pattern matching is implemented and used in each language.

Haskell is statically typed. The types are known at compile time and stated clearly in every function signature. Pattern matching allows the internals of these input types to be accessed and allows branching on their values and structure. The compiler can prove that functions are total, that every edge case is handled and that there can be no unexpected behavior.

Example in Haskell:

isAbove :: (Double, Double) -> (Double, Double) -> Bool
isAbove (_, y1) (_, y2) = y1 >= y2

This function is guaranteed to receive two pairs of doubles and can never fail.

Python, in contrast, is dynamically typed. Types are known only at runtime. Functions cannot generally be total without resorting to vague catch-all statements. Pattern matching may help deal with this ambiguity by both branching on runtime input type and then teasing apart the types.

Example in Python:

def isAbove(x1, x2):
    match (x1, x2):
        case ((_, y1), (_, y2)): # if points are tuples
            return y1 >= y2
        case (Point(y=y1), Point(y=y2)): # if points are objects
            return y1 >= y2
        case (_, None): # if one point is undefined
            return True
        case (None, _): # if other point is undefined
            return False
        # potentially more cases

This function accounts for various input types allowing runtime polymorphism.

Type annotations and type checking in Python may simplify the code. They certainly clarify the programmers intent. For well-designed application code, where the program is fully typechecked, the input type annotations may be trustworthy. But for library code, where outside users provide function input, there is no guarantee that the runtime type will match the given annotation. Thus, in Python, pattern matching is not just about breaking down a known type but also about discovering a value’s type and branching accordingly.

A second major difference between Haskell and Python is their paradigm: functional versus mixed.

Haskell is a functional programming language. A “function” in a functional language is like a mathematical function in that it maps a value in one domain to a value in a new domain. The inputs to a function typically hide no data and contain no methods. The data structure is typically exposed and immutable. Tracing patterns across such data is straightforward.

Python is a mixed programming language. When it is written in an object oriented (OOP) style, pattern matching becomes philosophically problematic. Pattern matching needs to access the substructure of an object, but this tends to break the encapsulation principle. Python is not strict about encapsulation. There is no technical divide between public and private data and pattern matching allows access to all fields. However, pattern matching against the internal structure of an object may access fields that are considered private and that may be less stable that the supported API.

A third difference between Haskell and Python is that Haskell heavily uses sum types.

In their simplest form, sum types are like enums:

data Colors = Red | Green | Blue  

But in Haskell, the types may also be parameterized:

data BTree a :: Node (BTree a) (BTree a) | Leaf a 

This type describes a binary tree. Recursive functions may navigate it with pattern matching:

treeSize :: BTree a -> Int
treeSize (Node rhs lhs) = 1 + treeSize rhs + treeSize lhs
treeSize (Leaf _) = 1

This function counts the number of nodes and leaves in a tree.

Python can emulate sum types. But this is a design choice. There are other options. In Haskell, sum types are universal.

Python supports optional static type checking, multiple paradigms, and various ways to specify data. Some approaches work well with pattern matching, while others do not, making pattern matching more niche in Python. In Haskell, the static type system, functional paradigm, and data model all align well with pattern matching, making it central to the language.