Log Router —
One emit(),
Many Formats.
A single emit(message) call routes strings to plain text, dicts to JSON, exceptions to tracebacks, and bytes to base64 — all via Python's @singledispatch. This is polymorphism in production: one call site, behavior chosen by the argument's type.
Explore the running router below, inspect the code, then add your own format and watch the call site stay untouched.
🎯 By the end you can…
- Read every line of the router
- Predict which handler any value reaches
- Explain the MRO and why
boolis anint - Extend the router with classes, ABCs and unions
- Spot the mistakes that fail silently
- Test a dispatcher with
unittest
📋 You should already know
- Functions and calls
- What a class is (roughly)
Decorators and the MRO are taught in the primer.
Why a "Log Router"?
📨 One Function, Four Formats
Logging systems receive wildly different things: a plain message, structured JSON, an exception object, a blob of bytes. The naive fix is a chain of isinstance checks inside one giant function. The polymorphic fix is @singledispatch: one function name, one signature, and a separate implementation registered per type.
The call site — emit(message) — never changes. Add a new type tomorrow, and the call site still doesn't change. That is open/closed in a single decorator.
Why @singledispatch rather than a class hierarchy?
You cannot edit str, dict, Exception or bytes to add an emit() method — they are built-ins. Single dispatch lets you register behavior externally, keyed on the type, without touching the classes.
The alternative would be subclassing str and dict just to attach a method — heavy and surprising. Dispatch by type keeps the built-ins alone and puts the routing in one function.
🤔 What does “dispatch” mean?
Dispatching means choosing which piece of code to run for a call. In everyday object-oriented code Python dispatches on the object a method belongs to: dog.speak() and cat.speak() run different code because dog and cat have different classes. Single dispatch uses the same idea, but for an ordinary function and keyed on the type of its first argument. The function is outside the classes, so it works even for classes you did not write.
Methods on your class
Best when you own the class: shape.area(). The behaviour lives inside the type.
Duck typing
Best when objects already share a method name. No registry, no type checks; see the Test Double Lab.
Single dispatch
Best when the types are not yours (str, dict, third-party classes) and you want one function name for all of them.
🪜 The problem, shown: the isinstance ladder
Here is the router written without @singledispatch. It works, and it is the version most programmers write first. Read it line by line, and pay special attention to the last six lines: they reveal a bug that has nothing to do with typos.
Why the ladder hurts: (1) every new type means editing this one function; (2) the order of the checks silently changes the result, as emit_wrong_order(True) proved; (3) a plugin in another file cannot add a rung without touching your source; (4) the whole function must be tested as one unit. The rest of this lesson shows how @singledispatch removes all four problems.
🧰 What you will be able to do
This page is written for someone who has met functions and classes but has not yet met decorators, MRO or abstract base classes. Each is introduced where it is needed. By the end you can read every line of the router, predict which handler any value will reach, extend it safely, test it, and recognise the situations where @singledispatch is the wrong tool.
Two Python Ideas Behind Every Line
The router rests on two ideas that beginners often meet here for the first time: types and their family trees (the MRO) and decorators. If you already know both, skim the cards and jump to the live router. If not, each idea has a short program you can run and read line by line.
How to study with this page. ① Predict: open “Predict first” and guess the output before pressing Run. ② Run the program. ③ Read the line-by-line explanation: tap any line, or switch on Step mode and press Next to go one line at a time while the matching line lights up. ④ Change one thing in the playground and predict again.
🌳 Idea 1 — every value has a type, and types form a family tree
When Python creates 42 it creates an object of class int. Classes can inherit from other classes, and the ordered list of ancestors is called the MRO (Method Resolution Order). @singledispatch uses nothing except this list: it takes the type of your argument, walks its MRO from the front, and runs the first handler it has registered.
Takeaway: type(x) is the exact class, isinstance(x, C) is “C or any of its subclasses”, and __mro__ is the search path. Remember that bool is a kind of int.
🎀 Idea 2 — a decorator is a function that receives a function
You will see @singledispatch and @emit.register above function definitions. The @ line is just a shortcut for name = decorator(name). Two flavours matter here: a decorator that wraps a function in a new one, and a decorator that records a function and returns it unchanged. @emit.register is the second kind.
Takeaway: @emit.register does not change your handler; it stores it in a table when the file is loaded. That table is what the dispatcher searches at call time.
🗒️ Three small reminders used on this page
f"{x!r}"
Inserts x into the text. !r uses repr(), so strings keep their quotes and the output is unambiguous.
message: str
A label saying what type a parameter is meant to be. Python ignores it at run time, except that @emit.register reads it.
def _(...)
A single underscore is the conventional name for “I will never call this directly”. Handlers are reached through emit.
Try It — Watch the Lookup Happen
This panel models the dispatcher. Switch handlers on and off with the pills, then send a message. The trace shows the exact lookup chain: the dispatcher starts at the message’s own class and walks up its family tree until it finds a registered handler. A ✗ means “no handler here”, a ✓ means “found”.
Three experiments:
- Send a bool with the default handlers. Then switch on
intand send it again. Then switch onbool. Predict each result first. - Send KeyError. It has no handler of its own; which ancestor catches it?
- Switch off
Exceptionand send ValueError again.
object is always there):
emit(msg). Which handler runs? Press a button to find out.
Why does True print as [RAW] True by default? Only str, dict, Exception and bytes are registered. True walks bool → int → object, finds nothing until object, and gets the base function. Register int and it is claimed by the int handler; register bool and it gets its own.
This is a model written in JavaScript so it responds instantly. The real thing runs as Python in section 05, and you can run that program in your browser there. The model uses the same lookup rule as the real dispatcher for ordinary classes.
How @singledispatch Works
When you call emit(x), Python does not run the function body directly. The dispatcher inspects type(x), looks for a registered handler, and calls it. Three programs below take the mechanism apart: first a hand-built version, then the real registry, then the famous bool surprise.
The dispatch algorithm
- Take the type of the first positional argument.
cls = type(args[0]). Keyword arguments are not used:emit(message="hi")raisesTypeError: emit requires at least 1 positional argument. - Check the cache. If this class was looked up before, reuse the answer.
- Walk the class’s MRO from the most specific class to
object. If abstract base classes (such asMapping) are registered, the dispatcher slots them into the right place in that search order. - The first registered class wins. If two unrelated ABCs match equally well, Python raises
RuntimeError: Ambiguous dispatchinstead of guessing. - Fallback. The base function is registered for
object, so a match always exists. - Cache and invalidate. The result is cached; every call to
registerclears the cache.
🔨 Build a tiny one yourself
The best way to trust a mechanism is to build it. This 36-line version has the same register, dispatch and registry names as the real one.
🔍 Inspect the real registry
Registry visualisation
| Type sent | Lookup order (MRO) | Handler found |
|---|---|---|
"hi" | str → object | emit(str) ✓ |
{"a": 1} | dict → object | emit(dict) ✓ |
ValueError(...) | ValueError → Exception → BaseException → object | emit(Exception) ✓ |
b"hi" | bytes → object | emit(bytes) ✓ |
42 | int → object | base emit (fallback) |
True | bool → int → object | base emit (no int registered) |
3.14 | float → object | base emit |
⚖️ The bool / int surprise
Rule: registering int also captures bool, until you register bool yourself. Registration order does not matter; the position in the MRO does.
Where this beats isinstance
| Concern | isinstance ladder | @singledispatch |
|---|---|---|
| Add a new type | Edit the ladder | Write a new @emit.register |
| Order sensitivity | Wrong order gives wrong handler | Most specific class always wins |
| Locate behaviour | Grep one giant function | One small function per type |
| Testability | Test the whole ladder | Test each handler alone |
| Plugins | Must edit your file | Other modules register their own |
The Full Source, Explained
This is the complete router from the hero. Run it, then read each line. Tap a line in the code to jump to its explanation, or use Step mode.
The complete program
Why [RAW] True? No handler exists for bool or int, so True reaches the base function. Section 04 shows how registering int changes this.
Design notes
- Every handler returns a string, so the caller can always
print(emit(x)). - Errors are not hidden. If a dict holds something
json.dumpscannot encode, it raisesTypeError, which is the right outcome. - There is no handler for
ValueError. TheExceptionhandler covers every exception class.
Add a New Format Without Touching the Call Site
This is where single dispatch earns its keep. Every program below is complete: press Run on any of them without running another first.
1 · A new built-in type: list
2 · Your own class, and inheritance from two parents
3 · Three ways to register, at run time
4 · Abstract base classes
When two ABCs tie
5 · Methods: singledispatchmethod
6 · A strict router that refuses unknown types
7 · The cache, and replacing the base
Design rule: add a handler only when the default is wrong for that type. Each handler is a cost you must test and maintain.
Classic Mistakes, Reproduced
Watch each mistake fail. Predict the output first, then run it.
1 · No annotation on the handler
2 · Expecting dispatch on the second argument
The most dangerous mistake because nothing fails; the answer is just wrong.
3 · Handlers that return different things
4 · Trying to edit the registry directly
emit.registry[int] = f raises TypeError: 'mappingproxy' object does not support item assignment. Use emit.register(int, f), which also clears the cache.
🩺 Error-message decoder
| Message (shortened) | Cause | Fix |
|---|---|---|
Invalid first argument to register() | Handler without annotation, bare @register. | Annotate, or pass the class. |
emit requires at least 1 positional argument | Called with a keyword, emit(message=x). | Pass the value positionally. |
Ambiguous dispatch: ... or ... | Two unrelated ABCs match. | Register a concrete class. |
'mappingproxy' object does not support item assignment | Edited the read-only registry. | Use register(). |
sequence item 1: expected str instance, NoneType found | A handler returned None. | Make every handler return a string. |
NameError: name 'emit' is not defined | Ran a snippet that depends on earlier code. | Run the complete program first. |
Patterns This Enables
Single dispatch is a small tool with outsized impact. These are the patterns you can build with it.
Plugin Systems
Each plugin registers a handler for its own event type. Core dispatch code never imports plugins directly.
Serialization
One serialize(obj) that handles dict, list, datetime, UUID, your domain models — without if/elif.
Report Rendering
Render any result type as a chart or table: register a handler per metric type, one render(result) call site.
Test Data Builders
Each domain type has a handler that returns realistic sample data. Build fixtures by type, not by hand.
Type-Routed APIs
Framework routes incoming request.data to the right handler based on its type — JSON, form data, file upload, raw bytes.
Notification Delivery
One notify(message) route — Email, SMS, Push payloads each handled by their registered function.
Singledispatch Cheat Sheet
🏷 Register Handlers
| Pattern | Meaning |
|---|---|
| @emit.register | Uses first-parameter annotation (required) |
| @emit.register(str) | Explicit type argument |
| emit.register(list, func) | Imperative form |
| emit.dispatch(int) | Look up handler for a type |
| emit.registry | Read-only mappingproxy |
| emit._clear_cache() | Clear dispatch cache (rare) |
🔍 MRO Gotchas
| Situation | Handler used |
|---|---|
| bool, no int registered | Fallback (base) |
| bool, int registered | int handler |
| bool, bool registered | bool handler |
| ValueError, Exception reg'd | Exception handler |
| Subclass of registered type | Closest registered ancestor |
| object registered explicitly | That handler replaces the fallback |
| Two unrelated ABCs match | RuntimeError: ambiguous |
⚠️ Common Pitfalls
| Mistake | Fix |
|---|---|
| No annotation | Annotate or pass type to register |
| Dispatch on 2+ args | Only first arg works; use a (type, type) table |
| Keyword call emit(message=x) | TypeError: pass positionally |
| Mutating registry | Use .register(), not .registry dict |
| Mismatched return types | Keep handlers consistent |
| Silent fallback on unknown types | Make the base raise TypeError (strict router) |
🛠 Handy Imports
| Import | Use |
|---|---|
| from functools import singledispatch | Core decorator |
| from functools import singledispatchmethod | For methods |
| import json | Serialize dicts |
| import base64 | Encode bytes |
| import traceback | Format exceptions |
| import datetime | Common dispatch target |
Extend the Router
Try each challenge in the playground first. Open the solution only to compare.
Challenge 1 · Batches that route each item
Make a list handler that sends every item through emit, including nested lists.
Show the solution
Challenge 2 · Mask secrets
Create a type whose value is never printed. Hint: subclass str.
Show the solution
Challenge 3 · All numbers at once
Format every numeric type without listing them. Hint: the numbers module.
Show the solution
Challenge 4 · One handler, two types
Avoid duplicate handlers. Hint: union annotations, Python 3.11+.
Show the solution
✅ Prove it with automated tests
Each handler is a separate function, so each rule becomes a small test.
Quick Knowledge Check
Twelve questions on dispatch, the MRO, ABCs and the mistakes section. Instant feedback after each answer.
Great effort — review any missed questions above.
Free-Form Python Playground
Write and run any Python code — including @singledispatch. Use it to prototype your own handlers, break things on purpose, then fix them.
Subscribe to Poetic Bytes
Level up your Python knowledge.
In-depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.
🔒 Your email is never shared or sold.
Explore the Full OOP Series
This router is one of ten live projects in the Polymorphism guide. Explore the others or revisit the pillars.
Takeaways
🏁 What You've Learned
You saw how a single emit(message) call routes plain strings, structured JSON, exception objects and raw bytes to four different handlers — with no isinstance ladder anywhere. That is single-dispatch polymorphism.
You also saw why this is open/closed: extending the router is a new @emit.register function, never a modification of the call site. And you confirmed the sharp edges — bool is an int, annotations are required, only the first argument dispatches.
Key Takeaways
- @singledispatch turns one function name into a dispatcher — handlers are registered by type.
- Dispatch walks up the MRO: closest registered ancestor wins, base function is the fallback.
- Only the first argument dispatches — for two-dimensional routing, use a dict keyed by
(type1, type2). - bool is a subclass of int — register
boolexplicitly if you want it treated separately. - Registration order never matters; MRO position does. An explicit
boolhandler beatsintin any order. - Extend without modifying — the call site never changes, so existing tests stay green.
- Best for unowned types — built-ins, third-party classes, or domain types you shouldn't subclass.
🙋 Frequently asked questions
When should I NOT use @singledispatch?
When you own the classes, give them a method (obj.emit()). When two or three types are involved and will never grow, a plain if isinstance is simpler. Dispatch pays off when types are open-ended.
Why does my second parameter's annotation do nothing?
Only the first positional argument is used. See the mistakes section; use a table keyed by type pairs.
Is it slow?
Lookups are cached per class, so repeated calls are cheap. Registering clears the cache.
Can I dispatch on a method's argument?
Yes: functools.singledispatchmethod, shown in section 06.
📚 Glossary
Choosing which code runs for a call.
A function registered for one type.
The type-to-function table.
Ordered list of a class and its ancestors.
A function that takes a function.
Abstract base class such as Mapping.
The base function, registered for object.
Extend by adding code, not editing.
🔗 Further reading
- functools.singledispatch — official documentation.
- PEP 443 — the proposal that introduced it.
✨ One line, many formats. That's polymorphism in one function. Now go build a router for your own domain.
— Glenn Junsay Pansensoy, Poetic Bytes