Choose between if/elif chains, dict dispatch, and match/case¶
The question. You have a function that branches on a single value — an HTTP status code, an event type, a command name — and you need to pick one of three tools: an if/elif chain, a dict of handlers, or a match/case block. Which one does this branching deserve?
The short answer: start with if/elif. Reach for dict dispatch when you have many branches that are each a simple lookup-and-call. Reach for match when the branches depend on shape (fields, nesting) as well as value, or when destructuring would otherwise clutter every branch.
# The problem: route an HTTP response (a dict with `status` and `body`)
ok_response = {'status': 200, 'body': {'data': [1, 2, 3]}}
created_response = {'status': 201, 'body': {'id': 42}}
not_found = {'status': 404, 'body': {'error': 'missing'}}
server_error = {'status': 500, 'body': {'error': 'internal'}}
unknown = {'status': 999, 'body': {}}
# The default: if/elif. Every branch is free-form; nothing to set up.
def route(response):
status = response['status']
if status == 200:
return f"OK — got {len(response['body']['data'])} items"
elif status == 201:
return f"Created with id {response['body']['id']}"
elif status == 404:
return 'Not found — fall back to cache'
elif status == 500:
return 'Server error — back off and retry'
else:
return f'Unexpected status {status}'
for r in [ok_response, created_response, not_found, server_error, unknown]:
print(route(r))
# Variant: dict dispatch — adding a case is one line
def handle_ok(r): return f"OK — got {len(r['body']['data'])} items"
def handle_created(r): return f"Created with id {r['body']['id']}"
def handle_not_found(_): return 'Not found — fall back to cache'
def handle_server_error(_): return 'Server error — back off and retry'
def handle_unknown(r): return f"Unexpected status {r['status']}"
ROUTES = {
200: handle_ok,
201: handle_created,
404: handle_not_found,
500: handle_server_error,
}
def route_dict(response):
handler = ROUTES.get(response['status'], handle_unknown)
return handler(response)
for r in [ok_response, created_response, not_found, server_error, unknown]:
print(route_dict(r))
# Variant: match/case — branch on shape and destructure at once
def route_match(response):
match response:
case {'status': 200, 'body': {'data': data}}:
return f'OK — got {len(data)} items'
case {'status': 201, 'body': {'id': id}}:
return f'Created with id {id}'
case {'status': 404}:
return 'Not found — fall back to cache'
case {'status': 500}:
return 'Server error — back off and retry'
case {'status': status}:
return f'Unexpected status {status}'
for r in [ok_response, created_response, not_found, server_error, unknown]:
print(route_match(r))
Why each form earns its place¶
if/elif is the baseline because it assumes nothing. Each branch can return, raise, call, or log — whatever the case demands. The reader sees, top to bottom, exactly what runs. The cost is linear-in-branches repetition of status == … and a growing wall of elif.
Dict dispatch replaces the branching with a lookup. Each case becomes a named handler, and the routing logic collapses into a data structure — ROUTES.get(key, default). Adding a case is one line in the dict. That's why plugin systems, command parsers, and event dispatchers gravitate to this pattern: they can be extended at runtime (ROUTES[new_key] = new_handler) and introspected (ROUTES.keys() tells you what's supported).
match/case earns its place when branches are keyed on shape, not just value. The pattern {'status': 200, 'body': {'data': data}} matches the structure and binds data in one step. The equivalent if chain has to response['body']['data'] every time, and the equivalent dict dispatch would need each handler to dig through the response itself. match lets the branch condition and the destructuring be the same expression.
Trade-offs¶
| Use this… | When… | Costs |
|---|---|---|
if/elif |
A handful of branches; each branch does distinct work; conditions go beyond equality (if score > 0.8). |
Scales poorly past a dozen cases; repeats the test expression. |
| Dict dispatch | Many branches keyed on one value; each branch is a callable; you want runtime-extensible routing. | Each case becomes a separate function (overkill for one-liners); logic spread across the handler table and its handlers. |
match/case |
Branches depend on shape as well as value; you want to destructure dicts, dataclasses, or tuples. | Python 3.10+ only; pattern syntax has its own gotchas (capture-vs-compare; case 4 \| 5: matches the literals 4 or 5, not "any 4xx"). |
In practice, the choice depends on what's likely to change. If branches grow in number but keep the same shape, dict dispatch wins. If new branches need new shapes (different fields, different types), match wins. If branches are heterogeneous one-offs, if/elif is fine — and you can always combine them: match to peel off the outer shape, then a dict inside one branch for fine-grained routing.
Related reading¶
match/casesyntax — every pattern type at a glance.- Structural pattern matching in context — when
matchearns its place and when it doesn't. - Use guard clauses to flatten nested conditions — the refactor that often comes before any of these dispatch styles.