Written 25 March 2024 · Last edited 29 March 2026 ~ 5 min read
The Python dictionary dispatch pattern
Benjamin Clark
Part 2 of 3 in Software Odds and Ends
Introduction
I was reviewing a PR recently and found a function dispatching to seven different handlers based on a string argument. It looked roughly like this:
def handle(operation: str, payload: str) -> None:
if operation == "create":
handle_create(payload)
elif operation == "update":
handle_update(payload)
elif operation == "delete":
handle_delete(payload)
# ... four more
else:
raise ValueError(f"Unsupported operation: {operation}")
It worked. For the first three branches it was readable. By branch seven it was a wall of text, and the next engineer extending it would almost certainly tack an eighth elif onto the bottom without noticing the else clause existed.
This is the problem the dispatch pattern solves. Python’s treatment of functions as first-class citizens makes it clean to implement.
The dispatch pattern
A dispatcher sits between the caller and the handlers. The caller tells it what it wants; it routes to the right implementation. Neither side knows about the other directly.
Swapping a Bosch oven for a Siemens one means updating the dispatcher — everything calling it stays the same. All the routing logic lives in one place rather than spread across multiple if/elif chains.
---
title: Bosch setup
---
flowchart LR
boschOven
Dispatcher
robot
Dispatcher -- directs to --> boschOven
robot -- consumes --> Dispatcher
---
title: Siemens setup
---
flowchart LR
siemensOven
Dispatcher
robot
Dispatcher -- directs to --> siemensOven
robot -- consumes --> Dispatcher
Dictionary dispatch in Python
In Python, functions are first-class citizens — stored as dictionary values, called like any other callable. Here’s the cookie example:
from dataclasses import dataclass
from collections import defaultdict
@dataclass
class Cookie:
type: str
def bosch_oven(cookie: Cookie) -> None:
print(f"{cookie.type} cookie baked with Bosch")
def siemens_oven(cookie: Cookie) -> None:
print(f"{cookie.type} cookie baked with Siemens")
def default(cookie: Cookie) -> None:
print(f"Unable to interact with {cookie.type} cookie")
dispatcher = defaultdict(lambda: default)
dispatcher["bake"] = bosch_oven
dispatcher["bake"](Cookie("chocolate"))
# chocolate cookie baked with Bosch
dispatcher["eat"](Cookie("chocolate"))
# Unable to interact with chocolate cookie
siemens_oven is defined but not wired in. Swapping suppliers means changing one line — dispatcher["bake"] = siemens_oven. Adding "frost" is define-and-register; no elif chain to extend. Unrecognised keys fall through to default via defaultdict, cleaner than a trailing else.
A more realistic example
The structure below is something I’ve used as the basis for Lambda/API Gateway handlers — the dogs and plants are invented, the shape is not.
An abstract base class defines the dispatch interface; concrete classes implement it per endpoint; a top-level defaultdict maps paths to handler instances.
from collections import defaultdict
import json
from abc import ABC, abstractmethod
from typing import Set
dogs = ["Cabbage", "Gravy", "Muffin"]
class IDispatch(ABC):
@abstractmethod
def dispatch(self, method: str, payload: str):
pass
class Dogs(IDispatch):
@staticmethod
def dispatch(method: str, payload: str):
endpoints = {"get": Dogs.get}
try:
return endpoints[method.lower()]()
except KeyError:
return 405, f"'{method}' method not supported for endpoint"
@staticmethod
def get(**kwargs) -> tuple[int, str]:
return 200, json.dumps(dogs)
class Plants(IDispatch):
def __init__(self):
self.plants = Plants.read_plants_from_disk()
def dispatch(self, method: str, payload: str):
endpoints = {
"get": Plants.get,
"post": Plants.post
}
try:
return endpoints[method.lower()](self, plant=payload)
except KeyError:
return 405, f"'{method}' method not supported for endpoint"
def get(self, **kwargs) -> tuple[int, str]:
return 200, json.dumps(list(self.plants))
def post(self, plant: str, **kwargs) -> tuple[int, str]:
if plant in self.plants:
return 400, f"{plant} already managed by API."
try:
self.plants.update({plant})
self.persist_plants_to_disk()
return 200, f"Successfully added {plant} to managed plants"
except Exception as e:
return 400, str(e)
def persist_plants_to_disk(self):
with open("plants.txt", "w") as plant_file:
for plant in self.plants:
plant_file.write(f"{plant}\n")
@staticmethod
def read_plants_from_disk() -> Set[str]:
returned_plants = set()
try:
with open("plants.txt", "r") as plant_file:
for line in plant_file:
returned_plants.update({line.strip()})
except IOError:
pass
return returned_plants
dispatcher = defaultdict(lambda: {"status_code": 404, "response": "Endpoint not found"})
dispatcher["/plant"] = Plants()
dispatcher["/dog"] = Dogs()
print(json.dumps(dispatcher["/potato"]))
# {"status_code": 404, "response": "Endpoint not found"}
status, response = dispatcher["/plant"].dispatch("GET", "")
# 200, "[]"
status, response = dispatcher["/plant"].dispatch("POST", "cactus")
# 200, "Successfully added cactus to managed plants"
status, response = dispatcher["/plant"].dispatch("POST", "cactus")
# 400, "cactus already managed by API."
status, response = dispatcher["/plant"].dispatch("DELETE", "lilly")
# 405, "'DELETE' method not supported for endpoint"
status, response = dispatcher["/dog"].dispatch("GET", "")
# 200, "[\"Cabbage\", \"Gravy\", \"Muffin\"]"
status, response = dispatcher["/dog"].dispatch("POST", "")
# 405, "'POST' method not supported for endpoint"
The top-level dispatcher handles unknown paths with a 404 default. Each endpoint class handles unsupported methods with a 405. The two layers are independent — extending either doesn’t affect the other.
When not to reach for it
If there are two or three branches that won’t change, a plain if/elif is more readable and easier to follow statically. The dispatch pattern earns its keep when cases are added regularly, or when swapping implementations needs to happen without touching call sites.
Dictionary-dispatched calls are hard for type checkers and IDEs to follow. mypy won’t infer the return type of dispatcher[operation](payload) the same way it would for a direct call, and jump-to-definition won’t work as expected. The abstract base class approach above helps, but doesn’t fully close the gap — something worth knowing before reaching for this in a codebase where static analysis coverage matters.
Conclusion
The PR that prompted this post had 43 lines of if/elif. Not a disaster, but the kind of thing that compounds — someone adds a case, then someone else does, and six months later nobody wants to touch it. The dictionary dispatch pattern keeps that from happening, and Python’s first-class functions make it cheap to set up from the start.
Part 2 of 3 in Software Odds and Ends