What is the Switch-Case Equivalent in Python?

Janne Kemppainen |

Historically, the Python syntax hasn’t had a switch-case statement. In 2006 Guido van Rossum, the original author of Python, proposed different alternatives for the switch-case syntax in PEP 3103 but they all seemed to have some problems and the idea didn’t gain enough popular support. The proposal was therefore rejected. Python version 3.10 changes this.

Pattern matching is the switch-case equivalent in Python 3.10 and newer. A pattern matching statement starts with the match keyword, and it contains one or more case blocks.

Currently, Python 3.10 is only available as an alpha release.

It will take a long time until any library code can safely adapt this new functionality since Python 3.9 will reach its end of life approximately in October 2025. Therefore I’ve split this post into two parts, depending on which version you need to target. In the first part we’ll go through the new pattern matching model, then I will show how to emulate switch-case in older versions. Scroll down if you’re only interested in the old method.

Switch-case in other languages

Before going to the Python details let’s start with another language that implements the switch-case statement, C++. The syntax is simple, you start a block with switch, define conditions with case, and exit from the conditions with break, for example:

switch(i) {
    case 1: cout << "First case";
    break;
    case 2: cout << "Second case";
    break;
    default: cout << "Didn't match a case";
}

The above code uses the value of i to decide what should be printed on the console. If there is no match then the default value will be printed. If the break statement is excluded then the execution would continue on the next line.

switch(i) {
    case 1: cout << "First case";
    case 2: cout << "Second case";
    default: cout << "Didn't match a case";
}

With an input value of 1 the code above would print out all cases. Typically you’d want to have the break statements there, forgetting to add one can be a source for bugs in your code.

Next, let’s see how this translates in Python.

Pattern matching

As I already mentioned, from Python 3.10 onwards you can start using a feature called structural pattern matching. The full specification is available on PEP 634, and a friendlier introduction can be found from PEP 636. Here I’m trying to give you a quick introduction to the concept.

Pattern mathcing is not just your typical switch-case, but it’s actually much more powerful than that. However, you can use it just like a switch-case statement too, this is equivalent to the first C++ example:

match i:
    case 1:
        print("First case")
    case 2:
        print("Second case")
    case _:
        print("Didn't match a case")

The main differences to C++ are that the keyword is match instead of switch, you don’t need to use break to prevent the code from continuing with the next case, and the default case is defined with an underscore, which will always match.

Matching sequences

In addition to matching literal patterns such as string literals, number literals, boolean values or None, you can create more complicated capture patterns. Your cases can also be lists or tuples, allowing you to match different combinations.

Let’s say that you need to do web requests based on an input where you get the HTTP method, destination URL, and possible data. Your function needs to support GET, POST and DELETE, and raise an error otherwise. The implementation with pattern matching and the Requests HTTP library could look like this (if we forget the existence of requests.request):

import requests

def do_request(method, url, data=None):
    match (method, data):
        case ("GET", None):
            return requests.get(url)
        case ("POST", _):
            return requests.post(url, data=data)
        case ("DELETE", None):
            return requests.delete(url)
        case _:
            raise ValueError("Invalid arguments")

The cases are matched based on the values within the tuple, so only the allowed argument combinations will be executed. If you attempt to add data to other methods than POST, you will get a ValueError.

The sequences can also vary in length. This is a dummy structure for a command line program that has different levels of help information, and two commands:

import sys

main():
    match sys.argv[1:]:
        case ["help"]:
            print("Print general help for all available options")
        case ["help", "start"]:
            print("Print detailed help for the start command")
        case ["help", "stop"]:
            print("Print detailed help for the stop command")
        case ["help", command]:
            print(f"No help entry for command: {command}")
        case ["start", *args]:
            print(f"Run start command with: {args}")
        case ["stop", *args]:
            print(f"Run stop command with: {args}")
        case []:
            print("No command defined!")
        case _:
            print("Invalid input")

if __name__ == "__main__":
    main()

The sys.argv variable contains the command line arguments for the program, and the first value is always the the name of the running script, so we use list slicing to drop that away and only use the remaining arguments.

If the input argument is help, then the program will print a general help text that could show basic usage and available options. Then typing help start would show more detailed information about the start functionality, and so on.

The fourth case captures help with an unknown command. This matches a sequence of two elements where the first item is the string "help", and the second item is saved in a variable called command. This really shows the power of pattern matching since you can also match the structure of the input, not just values.

The start and stop cases show how to match a sequence of items using the * operator. With sequence unpacking you can match zero or more items at any position of the sequence. Only one item is allowed to use a star in the pattern.

Or patterns

If both start and stop called the same function, it would not make much sense to separate these to different cases. Case patterns can use | as the OR operator to match many different values. The start and stop functionailty could be refactored to this:

match sys.argv[1:]:
    ...
    case [("start" | "stop") as command, *args]:
        print(f"Run {command} command with: {args}")
    ...

Here the first item in the list matches either "start" or "stop", and then the rest of the command is captured as a sequence. The as keyword stores the actual matched value as the command variable so that we can use it later.

This pattern has one restriction: the alternatives should bind the same variables. So you cannot have one side bind a value to x , and then try to use y on the other side like this:

case ["start", x] | ["stop", y]:

Matching objects

The matcher is not limited to primitive types and strings, but it works with objects too. Consider this simple User class that could be used in a web service:

from dataclasses import dataclass

@dataclass
class User:
    id: int
    username: str
    email: str
    admin: bool
    region: str

You might want to handle admin users in a different way than non-admin users. Additionally, maybe EU residents need some special treatment:

match user:
    case User(admin=True):
        print("This is an admin user")
    case User(region="EU"):
        print("This is a customer in the EU")
    case User():
        print("This is a customer somewhere else")
    case _:
        print("Error: Not a user!")

Even though the cases look like they are creating objects using constructors that is not the case. Pattern matching uses these constructor like definitions to find the correct match, any attribute that is left undefined will be handled as a wildcard.

Therefore the first case will match for all users that are admins. The second case applies to users whose region is "EU", and the third one matches any remaining users. The last case is used when the object is not a user, but for example None.

Big gotcha with constants!

So far I’ve been using literal values in the matching statements but wouldn’t it be nicer to define them as constants? This is where you need to be careful so that you don’t introduce bugs in your code!

If we continue with the User class from the previous section we could think that it’s cleaner to handle the EU special case with a constant so that we don’t need to use hard-coded values everywhere:

REGION_EU = "EU"

match user:
    ...
    case User(region=REGION_EU):
        print(f"This is a customer in {REGION_EU}")

Here we get to the point of capturing variable values. Now, you would expect that a user who is in North America ("NA") would not match, and the code would say that the customer is somewhere else. What actually happens is that the variable REGION_EU will now contain the string "NA" and the case will match!

This means that we can capture variables also from objects. So if we’re only interested in the region of non-admin users we could use this kind of pattern:

match user:
    case User(admin=False, region=region):
        print(f"This is a customer in {region}")
    case User():
        print("This is an admin user")
    case _:
        print("Error: Not a user!")

But how can we use constants then? They need to be dotted names to prevent the matcher from overriding the values. One option is to use enumerations:

from dataclasses import dataclass
from enum import Enum

class Region(str, Enum):
    AFRICA = "AF"
    ASIA = "AS"
    EUROPE = "EU"
    NORTH_AMERICA = "NA"
    OCEANIA = "OC"
    SOUTH_AND_CENTRAL_AMERICA = "SA"

@dataclass
class User:
    id: int
    username: str
    email: str
    admin: bool
    region: Region

...

match user:
    case User(admin=False, region=Region.EUROPE):
        print("European customer")
    case User(admin=False):
        print("Other customer")
    case User(admin=True):
        print("Admin user")
    case _:
        print("Error: Not a user!")

Cases with conditionals

Now you would think that was all and there couldn’t possibly be anything more to remember? Wrong! You can also include conditionals in the cases.

Let’s continue with the user example a little further. This time we want to handle the case where two users are in the same region:

match users:
    case [User() as u1, User() as u2] if u1.region == u2.region:
        print(f"Received two users in the same region: {u1.region}")
    case [User(), User()]:
        print(f"Received two users in different regions")
    case _:
        print("Received something else")

We can capture items in the patterns with the as keyword. With the if conditional the pattern will fully match only if the conditional expression also matches. If the part before if matches, the u1 and u2 variables will be initialized before evaluating the condition. They will remain available in later steps too, so be aware that this has the potential to ovewrite things even if the condition itself wouldn’t fully match due to the if-statement.

Alternative methods

If your Python version does not support pattern matching, then you need to use an alternative method to mimic switch-case. There are basically two ways to do it, so let’s see what they are.

If, elif, else

The most basic and easy way to emulate a switch statement is to use plain if else statements. The equivalent of the first C++ example would look like this:

def print_case(value):
    if value == 1:
        print("First case")
    elif value == 2:
        print("Second case")
    else:
        print("Didn't match a case")

The value is compared against every option until a match is found or until the final else statement is reached.

The code is quite easy to reason about but with lots of cases it can become difficult to manage. There are also many state comparisons that we need to write manually which can be error prone. However, this should probably be your choice for simple cases with only a few possible states. This is also what the Python docs suggest as the substitute.

Dictionary

Another way to achieve similar results is to put callables inside a dictionary. This method is also mentioned in the Python docs.

def print_case(value):
    cases = {
        1: lambda: print("First case"),
        2: lambda: print("Second case"),
        3: lambda: print("Third case"),
        4: lambda: print("Fourth case"),
    }
    cases.get(value, lambda: print("Didn't match a case"))()

As you can see this can look a bit cleaner with more options as compared to the if-else statements.

In this code each key in the dictionary contains a lambda function without parameters, which then calls the appropriate print function. Lambda functions are anonymous functions that can be stored in variables and then used just as normal functions.

The correct function is fetched using the get method from the dict and then it is called without arguments. The second argument of the get method is the default value which will print a message if a matching case was not found.

Notice the parentheses at the end of the last line. That is the actual function call.

If you don’t want to have a default action then this would work too:

def print_case(value):
    cases = {
        1: lambda: print("First case"),
        2: lambda: print("Second case"),
        3: lambda: print("Third case"),
        4: lambda: print("Fourth case"),
    }
    cases[value]()

If you try to call the print_case function with an invalid value such as 5 the code will raise a KeyError. This might be desired so that your program won’t continue with an invalid input. Just be sure to handle the error elsewhere in your code.

A more complex example

What if you need to add parameters to the cases?

Let’s change our example a bit. Now what if we wanted to create a function called calculate that takes an operator and two operands, and returns the result of the calculation? We could implement it like this:

def calculate(operator, x, y):
    cases = {
        "+": lambda a, b: a + b,
        "-": lambda a, b: a - b,
        "*": lambda a, b: a * b,
        "/": lambda a, b: a / b,
    }
    return cases[operator](x, y)

Each of the lambda functions now has two arguments that are defined before the colon. The final function call then passes the x and y arguments to the selected lambda. Notice that the lambda functions don’t contain the return keyword.

Here are some sample calls on the interactive shell.

>>> calculate("+", 50, 21)
71
>>> calculate("-", 50, 21)
29
>>> calculate("*", 50, 21)
1050
>>> calculate("/", 50, 21)
2.380952380952381

In my opinion this solution looks quite clean compared to the if-else option.

Remember that you don’t have to use lambda functions at all:

def add(x, y):
    return x + y

def subtract(x, y):
    return x - y

def multiply(x, y):
    return x * y

def divide(x, y):
    return x / y

def calculate(operator, x, y):
    cases = {
        "+": add,
        "-": subtract,
        "*": multiply,
        "/": divide,
    }
    return cases[operator](x, y)

The results are still the same. This time we used normal named functions that are used as objects in the case lookup dict.

If you’re really worried about performance then you could move the dictionary generation outside of the function. Then it won’t be regenerated each time calculate is called. In practice there shouldn’t be much difference.

Conclusion

Pattern matching is a whole new beast to tackle, and it can be a little intimidating at first. Hopefully this post has shed some light on the topic, and you feel comfortable to learn more. If you need to target older Python versions, you can still use the alternatives that I described here in the end.

Subscribe to my newsletter

What’s new with PäksTech? Subscribe to receive occasional emails where I will sum up stuff that has happened at the blog and what may be coming next.

powered by TinyLetter | Privacy Policy