The Power of Multiple Dispatch in Python
The Secret Sauce Behind Julia and Lisp — Now in Python
Have you ever wished your Python functions could respond more smartly — like really smartly — depending on all the argument types passed in? That’s multiple dispatch. It’s like function overloading’s cooler, more flexible cousin, and it’s built into languages like Julia and Common Lisp. But in Python? Well, it takes some extra work.
Let me take you on a little journey — story-first, code-always — to show you what works, what doesn’t, and what will save you time and sanity.
Part 1: Trying multipledispatch
(And Seeing Where It Cracks)
Let’s say we’re writing a program that greets people differently based on whether they’re Vegan, Vegetarian, or just a regular Person. Sounds easy?
Let’s start with the multipledispatch package:
from multipledispatch import dispatch
class Person:
pass
class Vegan(Person):
pass
class Vegetarian(Person):
pass
@dispatch(Vegan, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a vegan!"
@dispatch(Vegetarian, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a vegetarian!"
@dispatch(Person, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a person!"
# Now test it
print(greet(Vegan(), "Holger"))
print(greet(Vegetarian(), "Knulf"))
print(greet(Person(), "Ralph"))
Output:
Hi, I'm Holger! I'm a vegan!
Hi, I'm Knulf! I'm a vegetarian!
Hi, I'm Ralph! I'm a person!
Perfect! Or so we thought…
Now let’s define a new class:
class Human(Person):
pass
@dispatch(Human, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a human!"
print(greet(Human(), "Bob"))
Expected: Hi, I’m Bob! I’m a human!
Actual:
Hi, I'm Bob! I'm a person!
Boom. It picked the more general match and completely ignored the more specific one. Why? Because multipledispatch
resolves functions in the order they were defined and doesn’t search deeper.
This means it’s not actually multiple dispatch in the way we want it. It just grabs the first match it can find. Not great if you care about extensibility.
Part 2: Guido van Rossum to the Rescue (Sort of)
Thankfully, Guido van Rossum, Python’s creator, once suggested a DIY approach to multiple dispatch in a blog post.
Here’s the core of his idea:
registry = {}
class MultiMethod:
def __init__(self, name):
self.name = name
self.typemap = {}
def __call__(self, *args):
types = tuple(arg.__class__ for arg in args)
func = self.typemap.get(types)
if func is None:
raise TypeError("no match")
return func(*args)
def register(self, types, func):
if types in self.typemap:
raise TypeError("duplicate registration")
self.typemap[types] = func
def multimethod(*types):
def register(func):
name = func.__name__
mm = registry.setdefault(name, MultiMethod(name))
mm.register(types, func)
return mm
return register
Let’s test this with our previous example:
class Person: pass
class Vegan(Person): pass
class Vegetarian(Person): pass
@multimethod(Vegan, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a vegan!"
@multimethod(Vegetarian, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a vegetarian!"
@multimethod(Person, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a person!"
print(greet(Vegan(), "Holger"))
print(greet(Vegetarian(), "Knulf"))
print(greet(Person(), "Ralph"))
class Human(Person): pass
@multimethod(Human, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a human!"
print(greet(Human(), "Bob"))
Output:
Hi, I'm Holger! I'm a vegan!
Hi, I'm Knulf! I'm a vegetarian!
Hi, I'm Ralph! I'm a person!
Hi, I'm Bob! I'm a human!
Nice. Works! Now try this:
class Scientist(Person): pass
print(greet(Scientist(), "John"))
Boom. Back to TypeError: no match. Because Guido’s approach does not do fallback to superclass matches. It expects exact matches only.
So now what? Time to level up and implement a real multiple dispatch system…
Part 3: Rolling Our Own Smarter Dispatcher
We’ve seen how multipledispatch fails to pick the most specific type and how Guido’s version fails to fall back to parent classes. Let’s fix that.
Here’s our plan: if there’s no exact match, we’ll search all possible parent type combinations (in order of specificity) until we find a match. This will give us a real multiple dispatch mechanism.
Let’s build it:
from functools import reduce
# Helper functions for searching fallback combinations
def penalize(groups, penalty=100):
res = []
for i, group in enumerate(groups):
tmp = []
for j in range(len(group)):
tmp.append(i + penalty ** j)
res.append(tmp)
return res
def tuplize(groups, penalties):
res = []
for g, p in zip(groups, penalties):
tmp = []
for name, value in zip(g, p):
tmp.append((name, value))
res.append(tmp)
return res
def as_tuple(groups):
return tuplize(groups, penalize(groups))
def flatten1(lst):
res = []
for x in lst:
if type(x) is list:
res.extend(flatten1(x))
else:
res.append(x)
return res
def _combine(lst1, lst2):
return [flatten1((x, y)) for x in lst1 for y in lst2]
def combine(args):
return reduce(_combine, args)
def de_tuplize_list(tuplized):
group, penalties = list(zip(*tuplized))
return group, sum(penalties)
def de_tuplize(tuplized_groups):
return [de_tuplize_list(x) for x in tuplized_groups]
def sorted_combinations(groups):
res = sorted(de_tuplize(combine(as_tuple(groups))), key=lambda x: x[1])
return [comb for comb, penalty in res]
def get_parents(obj):
if obj.__class__ is type:
return [obj]
else:
return (obj.__class__).__mro__
def get_dispatch_combinations(args):
_types = [get_parents(obj) for obj in args]
return sorted_combinations(_types)
Now plug that into our improved MultiMethod
:
registry = {}
class MultiMethod:
def __init__(self, name):
self.name = name
self.typemap = {}
def __call__(self, *args):
types = tuple(arg.__class__ for arg in args)
func = self.typemap.get(types)
if func is None:
for combo in get_dispatch_combinations(args):
if combo in self.typemap:
func = self.typemap[combo]
break
if func is None:
raise TypeError("no match")
return func(*args)
def register(self, types, func):
self.typemap[types] = func
def multimethod(*types):
def register(func):
name = func.__name__
mm = registry.setdefault(name, MultiMethod(name))
mm.register(types, func)
return mm
return register
Let’s test it:
class Person: pass
class Vegan(Person): pass
class Vegetarian(Person): pass
class Human(Person): pass
class Scientist(Person): pass
@multimethod(Vegan, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a vegan!"
@multimethod(Vegetarian, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a vegetarian!"
@multimethod(Person, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a person!"
@multimethod(Human, str)
def greet(person, name):
return f"Hi, I'm {name}! I'm a human!"
# Testing
print(greet(Vegan(), "Holger"))
print(greet(Vegetarian(), "Knulf"))
print(greet(Person(), "Ralph"))
print(greet(Human(), "Bob"))
print(greet(Scientist(), "John"))
Output:
Hi, I'm Holger! I'm a vegan!
Hi, I'm Knulf! I'm a vegetarian!
Hi, I'm Ralph! I'm a person!
Hi, I'm Bob! I'm a human!
Hi, I'm John! I'm a person!
Now even Scientist works!
But Let’s Be Real — You Don’t Want to Maintain This
Yes, we did it. We created a real multiple dispatch engine in Python. But do you want to maintain this in production?
Didn’t think so.
Part 4: plum-dispatch
— When Python Finally Behaves Like Julia
So far, we’ve seen:
multipledispatch
: buggy and unpredictable- Guido’s version: educational but not production-safe
- Our own smart dispatcher: works, but feels like writing a compiler in your spare time
But what if I told you there’s a Python library that already handles this the right way, supports type hints, inheritance-aware dispatch, and is actively maintained?
The solution in Python is: plum-dispatch.
Why plum-dispatch
Rocks
- Uses Python’s own type annotations
- Picks the most specific matching method
- Clean syntax: just
@dispatch
- Allows you to define new methods for existing classes without touching them
Let’s dive right in.
Getting Started
Install it first:
uv add plum-dispatch
# or if you still are stuck with pip (learn `uv`!)
pip install plum-dispatch
A Familiar Example — Done Right
from plum import dispatch
class Person: pass
class Vegan(Person): pass
class Vegetarian(Person): pass
Now let’s define greet()
for different types of people:
@dispatch
def greet(person: Vegan, name: str):
return f"Hi, I'm {name}! I'm a vegan!"
@dispatch
def greet(person: Vegetarian, name: str):
return f"Hi, I'm {name}! I'm a vegetarian!"
@dispatch
def greet(person: Person, name: str):
return f"Hi, I'm {name}! I'm a person!"
And let’s run it:
print(greet(Vegan(), "Holger"))
print(greet(Vegetarian(), "Knulf"))
print(greet(Person(), "Ralph"))
Output:
Hi, I'm Holger! I'm a vegan!
Hi, I'm Knulf! I'm a vegetarian!
Hi, I'm Ralph! I'm a person!
Now add Human
:
class Human(Person): pass
@dispatch
def greet(person: Human, name: str):
return f"Hi, I'm {name}! I'm a human!"
print(greet(Human(), "Bob"))
It correctly outputs: Hi, I'm Bob! I'm a human!
Now add Scientist
:
class Scientist(Person): pass
print(greet(scientist, "John"))
We didn't defined a method greet()
for a Scientist. So it has to use the fallback to Person
.
Output: Hi, I'm John! I'm a person!
.
It correctly picks the fallback method for Scientist, since Scientist inherits from Person.
Now Let’s Go 2D: Add Another Method
Let’s define a meet()
method that depends on both arguments — say, who meets whom.
We can write very specific cases:
@dispatch
def meet(p1: Person, p2: Person):
return f"I'm a Person. Nice to meet you, Person!"
@dispatch
def meet(p1: Human, p2: Scientist):
return f"I'm a Human. Nice to meet you, Scientist!"
@dispatch
def meet(p1: Scientist, p2: Human):
return f"I'm a Scientist. Nice to meet you, Human!"
Let’s test them:
print(meet(Human(), Scientist()))
print(meet(Scientist(), Vegan()))
print(meet(Vegan(), Vegetarian()))
Output:
I'm a Human. Nice to meet you, Scientist!
I'm a Scientist. Nice to meet you, Person!
I'm a Person. Nice to meet you, Person!
Again, plum falls back to the most specific available method for the types given.
Now run:
print(meet(Vegan(), Vegetarian()))
print(meet(Vegan(), Human()))
print(meet(Human(), Scientist()))
print(meet(Scientist(), Vegan()))
Output:
I'm a Person. Nice to meet you, Person!
I'm a Person. Nice to meet you, Human!
I'm a Human. Nice to meet you, Scientist!
I'm a Scientist. Nice to meet you, Person!
This is more than just fun with decorators.
Multiple dispatch gives you:
- Extensible systems that obey the Open-Closed Principle
- Cleaner code: no endless
if isinstance(…)
chains - Easier testing and separation of concerns
- Real polymorphism, beyond what class-based OOP offers
This is a superpower for data pipelines, simulations, AI model inference, type-safe DSLs, or any system where behavior should adapt to types at runtime.
Final Verdict
multipledispatch
: avoid, buggy, abandoned- Guido’s DIY: fun, but no fallback logic
- Hand-rolled improved dispatch: works, but maintainability nightmare
- plum-dispatch: production-ready, expressive, elegant
Bonus Thought
This whole journey started because of Common Lisp and Julia (and R) — languages that treat code as data and dispatch as a core design pattern.
Python doesn’t have this natively, but with plum-dispatch, it almost feels like it does.
Happy multiple-dispatching!
And if you’re curious how Julia bakes this feature directly into the language, check out my intro to Julia here.
Let your functions finally grow up and handle real polymorphism.
Like this kind of thinking? I’ll be writing more soon on:
- Follow me on Medium @gwangjinkim for deep dives on Python, Lisp, system design, and developer thinking, and much more
- Subscribe on Substack: gwangjinkim.substack.com — coming soon with early essays, experiments & newsletters (just getting started).
- Follow my Ghost blog: everyhub.org — with hands-on tech tutorials and tools (most of them I cross-post here in medium, but not all of them).
Follow anywhere that fits your style — or all three if you want front-row seats to what’s coming next.
Maybe you also want to read:
Multiple Dispatch in R: