The Case Against Match-Case

Python

I have beefs with many conventional critiques of python. People complain about GIL, but 99% of their codebases don’t have the code where the GIL is a problem. People say python is slow, but for 99% of the applications, it is fine.

Along these lines lies the “match-case” pattern matching. Introduced in python 3.10, this language feature was highly praised. But I saw the tutorials and the real-world usage of this code. And I am confident that match-case is more problematic than useful. I’ll be clear about my take on match-case: it is bad, don’t use it.

Motivation of match-case

Let’s first read through the PEPs 622 and 635, describing the motivation for the match-case. They describe two major cases for match-case:

  • Cases where the variable is of unknown shape and/or type:
    • Standalone check of arguments
    • AST tree analysis
  • C-like “switch” statement

Let’s go through them one by one.

ASTs

A very simple pass for me. Unless you are writing your own python extension or interpreter, you don’t ever do AST tree analysis. So, this rationale helps very few people, and I don’t think it justifies a whole new semantics for everyone.

Checking variable types and length

Look at this example from the motivation in PEP622, where this code:

if (
    isinstance(value, (list, tuple)) and
    len(value) > 1 and
    isinstance(value[-1], (Promise, str))
):
    *value, label = value
    value = tuple(value)
else:
    label = key.replace(‘_’, ‘ ‘).title()

is replaced with

match value:
    case [*v, label := (Promise() | str())] if v:
        value = tuple(v)
    case _:
        label = key.replace(‘_’, ‘ ‘).title()

I don’t see why explicit isinstance() and len() are so terrible that they need to be replaced. In python philosophy, “explicit is better than implicit”, so in my playbook, calling explicit functions is totally fine. In this example, in my very subjective opinion, I understand the full expanded code better, than the shortened one. Remember that “short” doesn’t mean “good” by default.1

Actually, note that we are solving the problem of the variable number and types of arguments. What kind of functions in the real world can you call that would return a different number of arguments? Look at the example from the PEP:

match x:
    case host, port:
        mode = "http"
    case host, port, mode:
        pass
    # Etc.

Ask yourself, “where did x come from”? I would say it is implied here that x captures user input, another tool/API return, etc. So, we are effectively solving a user input sanitation problem, which normally requires way more than just checking a number of variables and their types. So the match-case semantics here only solve a fraction of the problem.

But somehow, I actually dig this use case, and I could have accepted it. With all my grudges, the code for the “host, port, mode” case seems appealing to me. I would have accepted this as a new tool… if it stopped here. But it didn’t.

Switch statement

Match-case semantics not only supports matching the number or types of variables, but also the exact matching of values, making it effectively a switch statement. And this is the reason why I absolutely hate match-case.

First, we already had a switch statement in python. It is called “if-elif-else”.

But second, and more importantly, match-case doesn’t function like if-elif-else, but people constantly say it does!

Here is the example from the PEP636 tutorial:

match command.split():
    case ["quit"]:
        print("Goodbye!")
        quit_game()
    case ["look"]:
        current_room.describe()
    case ["get", obj]:
        character.get(obj, current_room)
    case ["go", direction]:
        current_room = current_room.neighbor(direction)

Lovely, nice code. Except, something happened here implicitly that’s hard to notice. Suddenly, the two things, let’s say "get" and obj, have different contexts, even though used similarly. "get" is being read, while obj is being written.

If you don’t think this is a problem, consider this. Let’s say you are developing this game. You keep writing the code, and you end up using the "get" string explicitly in 5 other places in your code. These become magic constants, so you want to replace them. Additionally, your game is getting popular, and it is being translated into the Bulgarian language. Now, you must support "get" being a variable. So, you write:

quit, look, get, go = get_translations(language)

match command.split():
    case [quit]:
        print("Goodbye!")
        quit_game()
    case [look]:
        current_room.describe()
    case [get, obj]:
        character.get(obj, current_room)
    case [go, direction]:
        current_room = current_room.neighbor(direction)

This replacement would have been totally fine for the if-elif-else construct. Yet, this code here breaks. Can you guess how?

Here’s the answer: now, when you tell your character to “go”, you will instead attempt to pick up the non-existing object. And when you try to “look” somewhere, the game quits. Why? Because by replacing fixed strings with variables containing the values, you changed the way match-case is using this. Now [quit] doesn’t capture the cases with the value of quit variable, but instead captures any single-element list; and, similarly, [get, obj] captures any two-element list, even if the first word is "go".2

I really want to stress it:

There is no other common case in python, where using a pre-defined variable var="content" instead of an in-place constant "content" will change the behaviour of your code. Having match-case violate this intuition about variables being containers for values is a terrible, terrible idea.

The authors are aware of this problem. There is more than a screen-long chapter in PEP635 about considerations for using values from variables. They try to solve. And it gets worse.

Because there is a way to use variables! Just use something that has a “dotted name”, i.e. an attribute access, and this will be treated as a reading context again:

names = get_translations(language)

match command.split():
    case [names.quit]:
        print("Goodbye!")
        quit_game()
    case [names.look]:
        current_room.describe()
    case [names.get, obj]:
        character.get(obj, current_room)
    case [names.go, direction]:
        current_room = current_room.neighbor(direction)

So, bizarrely, if I write:

match cmd:
    case "option1":
        ...
    case option2:
        ...
    case option3.name:
        ...

then the options 1 and 2 behave differently, but 2 and 3 also behave differently, even though they are both variables, making 1 and 3 act the same again!3 No SFW emoji represents how I feel about this.

And the worst part? Most of the tutorials you find will focus on the switch behaviour rather than on structural pattern matching, just because switch is easier to explain - but it gives precisely the wrong intuition to the reader!

Consequences

The result of this is that the code written using match-case violates deeply ingrained intuitions about python variables established literally everywhere in the language. Even if it were to provide any significant value compared to pre-existing if-elif-else and isinstance(x, t) and len(x)==n patterns, it still would have been horrible. But it doesn’t even add any value in my opinion.

So, don’t use match-case. Your future self will thank you.


  1. When I’m playing code golf (a nerdy fun “game”, where the goal is to write the shortest possible program solving the problem) on Leetcode, I can write:

    class Solution:sortTheStudents=lambda f,s,e:sorted(s,key=lambda r:-r[e])
    

    But I shouldn’t do it in production because future debugging and maintenance of this will be 10x cheaper if I write a still horrible, but more descriptive:

    class Solution:
        def sortTheStudents(self, students_table: List[List[int]], exam_id: int) -> List[List[int]]:
            return sorted(students_table, key=lambda row: row[exam_id], reverse=True)
    
     ↩︎
  2. If this is unclear to you, and you are wondering “wait, why?” - congratulations! You have just been bamboozled by the exact problem I am describing. There is no intuitive way of understanding it, but to re-read the code with the “pattern matching” approach in mind. ↩︎

  3. This particular code actually won’t even compile to python bytecode! Instead, you will get a SyntaxError: name capture ‘option2’ makes remaining patterns unreachable, because case option2 matches any input. ↩︎


Like my writing? Connect with me on LinkedIn to chat or subscribe to me on Substack to get an email every time I post an essay.