Python Match-Case vs. Dictionary Lookup: Performance and Bytecode Internals
Introduction: The Evolution of Branching in Python
For years, the standard Pythonic idiom for handling large branching conditions was either a long chain of if-elif-else statements or a pre-allocated dictionary mapping keys to values or callable handlers. Developers often chose dictionaries because hash map lookups provide an expected time complexity of O(1), escaping the O(n) linear scanning inherent in sequential branching.
With the introduction of structural pattern matching (match-case) in Python 3.10 (via PEP 634), Python gained expressive, declarative pattern matching syntax. This raises an important performance question: Does CPython optimize literal match-case blocks into an internal jump table or hash table, or does it evaluate them sequentially?
Under the Hood: How CPython Implements Match-Case
In languages like C, C++, or Java, the compiler can compile dense switch-case statements into jump tables (branch tables) or binary search trees. However, CPython does not optimize literal match-case statements into jump tables or hash lookups.
In standard CPython (including Python 3.10 through 3.13), structural pattern matching compiles down to sequential evaluation using specialized bytecode instructions such as MATCH_VALUE, MATCH_MAPPING, and conditional jump instructions (like POP_JUMP_IF_FALSE).
Bytecode Comparison: Match-Case vs. If-Elif
Let's inspect the bytecode generated by CPython using the standard library's dis module:
import dis
def match_http(status_code):
match status_code:
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
dis.dis(match_http)The disassembly shows sequential execution:
2 0 LOAD_FAST 0 (status_code)
3 2 COPY 1
4 LOAD_CONST 1 (200)
6 MATCH_VALUE
8 POP_JUMP_IF_FALSE 7 (to 24)
10 POP_TOP
4 12 LOAD_CONST 2 ('OK')
14 RETURN_VALUE
5 >> 24 COPY 1
26 LOAD_CONST 3 (404)
28 MATCH_VALUE
30 POP_JUMP_IF_FALSE 7 (to 46)
32 POP_TOP
6 34 LOAD_CONST 4 ('Not Found')
36 RETURN_VALUE
...Each literal case simply loads a constant, calls MATCH_VALUE (which checks equality via == semantics), and jumps forward if false. As the number of branches grows, execution time scales linearly: O(n).
Match-Case vs. Dictionary Lookup: Performance Benchmarks
To evaluate the real-world performance differences, let's compare three approaches across different branch sizes:
- Match-Case: Standard
matchblock. - Persistent Dictionary: Module-level pre-allocated dictionary (
dict.get()). - Inline Dictionary: Creating the dictionary inside the function scope per call.
import timeit
# 1. Match-Case Function
def match_lookup(code):
match code:
case 100: return "Continue"
case 200: return "OK"
case 301: return "Moved Permanently"
case 400: return "Bad Request"
case 404: return "Not Found"
case 500: return "Internal Server Error"
case 503: return "Service Unavailable"
case _: return "Unknown"
# 2. Persistent Dictionary Function
HTTP_STATUS = {
100: "Continue",
200: "OK",
301: "Moved Permanently",
400: "Bad Request",
404: "Not Found",
500: "Internal Server Error",
503: "Service Unavailable",
}
def persistent_dict_lookup(code):
return HTTP_STATUS.get(code, "Unknown")
# 3. Inline Dictionary Function (Anti-pattern)
def inline_dict_lookup(code):
return {
100: "Continue",
200: "OK",
301: "Moved Permanently",
400: "Bad Request",
404: "Not Found",
500: "Internal Server Error",
503: "Service Unavailable",
}.get(code, "Unknown")
# Benchmark execution (Targeting the last element: 503)
print("Match-Case:", timeit.timeit(lambda: match_lookup(503), number=1_000_000))
print("Persistent Dict:", timeit.timeit(lambda: persistent_dict_lookup(503), number=1_000_000))
print("Inline Dict:", timeit.timeit(lambda: inline_dict_lookup(503), number=1_000_000))Benchmark Observations
- Persistent Dictionary: Provides consistent, predictable O(1) lookup times. For lookups near the end of a long list of branches (or misses), the persistent dictionary substantially outperforms
match-case. - Match-Case: For matches near the top (e.g., matching the first or second branch),
match-casecan be slightly faster than a dictionary lookup because it avoids dictionary hashing and function call overhead. However, its worst-case performance degrades linearly as branch count increases. - Inline Dictionary: Creating a new dictionary on every function invocation creates substantial transient memory allocation and initialization overhead, making it significantly slower than both persistent dictionaries and
match-case.
When Should You Use Each Approach?
Choose Persistent Dictionaries When:
- You are mapping simple keys directly to values, configurations, or handler functions.
- The branch size is large (dozens or hundreds of cases).
- The lookup occurs in a hot execution path where deterministic O(1) latency is required.
- The mapping keys are dynamic and determined at runtime.
Choose Structural Pattern Matching (match-case) When:
- You need structural matching, such as inspecting shapes, tuples, object attributes, or class instances (e.g.,
case Point(x, y) if x > 0:). - You are writing complex business logic with guard clauses (
ifconditions within cases). - The branch count is small to moderate, and readability/maintainability is your primary objective.
Conclusion
Python's match-case construct is designed for expressive structural pattern matching rather than being a high-performance jump-table switch. For simple static key-to-value translations across large sets of options, a persistent module-level dictionary remains the fastest and most memory-efficient choice.