If you’re routing logic based on the type of a dynamic value - parsing results, handling API responses, processing messages - you’re probably writing if-else chains. Stop. Dict dispatch is almost always better, and I have the benchmarks to prove it.

The Pattern You’re Writing

def if_else_dispatch(result, url):
    if result is None:
        return DownloadResult.failure(f"Failed: {url}", url)
    elif isinstance(result, list):
        return DownloadResult.success_multiple(result, url)
    elif isinstance(result, str):
        return DownloadResult.success_str(result, url)
    elif isinstance(result, int):
        return DownloadResult.success_int(result, url)
    elif isinstance(result, float):
        return DownloadResult.success_float(result, url)
    else:
        return DownloadResult.failure(result, url)

This is fine for 2-3 cases. At 5+ cases, it becomes a maintenance burden and a performance trap.

The Pattern You Should Write

dispatch_map = {
    type(None): DownloadResult.failure,
    list:       DownloadResult.success_multiple,
    str:        DownloadResult.success,
    int:        DownloadResult.success_int,
    float:      DownloadResult.success_float,
}

def dict_dispatch(result, url):
    handler = dispatch_map.get(type(result), DownloadResult.success)
    return handler(result, url)

Three lines of dispatch logic. One lookup. Done.

Why Dict Dispatch Wins

1. Constant Time, Every Time

I benchmarked both approaches with 1 million iterations across different input types. First, with a full 5-branch if-else chain:

Input Type Dict Dispatch If-Else Chain Dict Advantage
str 2.45s 3.13s 28% faster
int 2.42s 3.85s 59% faster
float 2.41s 4.62s 92% faster
list 2.44s 2.38s 2% slower
None 2.46s 1.69s 45% slower

Dict dispatch hovers around 2.4 seconds regardless of type. The if-else chain ranges from 1.69s to 4.62s depending on branch position - a 2.7x swing based purely on where your type lands in the chain.

2. Even Best-Case If-Else Can’t Beat It

“But what if I keep my if-else chain short?” Fair question. I stripped the chain down to just 2 checks plus a fallback:

def if_else_dispatch(result, url):
    if result is None:
        return DownloadResult.failure(f"Failed: {url}", url)
    elif isinstance(result, list):
        return DownloadResult.success_multiple(result, url)
    else:
        return DownloadResult.failure(result, url)

This is the best-case scenario for if-else: minimal branches, everything else falls through immediately. Results:

Input Type Dict Dispatch If-Else Chain Difference
str 2.332s 2.272s +2.6%
int 2.337s 2.298s +1.7%
float 2.331s 2.292s +1.7%

Both approaches make exactly 3 million function calls. If-else wins by ~40ms over a million iterations. That’s 40 nanoseconds per call - noise.

Here’s the thing: this minimal if-else chain doesn’t actually handle strings, ints, or floats. It just falls through to the error case. The moment you add proper handling for those types, you’re back to the first table where dict dispatch wins by 28-92%.

Dict dispatch handles all 5 types correctly and matches the performance of a broken 2-branch chain. That’s not a tradeoff - that’s a free lunch.

3. The Bytecode Tells the Story

Here’s what Python actually executes for dict_dispatch:

LOAD_GLOBAL    dispatch_map
LOAD_ATTR      get
LOAD_GLOBAL    type
LOAD_FAST      result
CALL           1
LOAD_GLOBAL    DownloadResult
LOAD_ATTR      success
CALL           2
STORE_FAST     handler
LOAD_FAST      handler
LOAD_FAST      result, url
CALL           2
RETURN_VALUE

That’s it. Load the map, call get() with the type, call the handler. Same bytecode path every time.

Now look at if_else_dispatch reaching the float branch in the full chain:

# Check 1: None
LOAD_FAST           result
POP_JUMP_IF_NOT_NONE → L1

# Check 2: list  
L1: LOAD_GLOBAL      isinstance
    LOAD_FAST        result
    LOAD_GLOBAL      list
    CALL             2
    TO_BOOL
    POP_JUMP_IF_FALSE → L2

# Check 3: str
L2: LOAD_GLOBAL      isinstance
    LOAD_FAST        result
    LOAD_GLOBAL      str
    CALL             2
    TO_BOOL
    POP_JUMP_IF_FALSE → L3

# Check 4: int
L3: LOAD_GLOBAL      isinstance
    LOAD_FAST        result
    LOAD_GLOBAL      int
    CALL             2
    TO_BOOL
    POP_JUMP_IF_FALSE → L4

# Check 5: float (finally!)
L4: LOAD_GLOBAL      isinstance
    LOAD_FAST        result
    LOAD_GLOBAL      float
    CALL             2
    TO_BOOL
    POP_JUMP_IF_FALSE → L5
    
    # Actually do the work
    LOAD_GLOBAL      DownloadResult
    LOAD_ATTR        success_float
    LOAD_FAST        result, url
    CALL             2
    RETURN_VALUE

Four isinstance() calls. Four TO_BOOL conversions. Four conditional jumps. All wasted work before you reach the handler.

The profiler confirms it: reaching the float branch requires 6 million function calls for 1 million iterations. Dict dispatch needs 3 million - half the work.

4. O(1) vs O(n) Scaling

Add a sixth type to your if-else chain? Every type after it gets slower. Add a sixth type to your dict? Zero performance impact on existing types.

# Adding a new type to if-else: O(n) penalty for later branches
elif isinstance(result, bytes):
    return DownloadResult.success_bytes(result, url)

# Adding a new type to dict: O(1), affects nothing
dispatch_map[bytes] = DownloadResult.success_bytes

5. Data-Driven Configuration

Dict dispatch separates routing logic from execution logic. Your dispatch map becomes data you can inspect, modify, or even load from configuration:

# Easy to introspect
supported_types = list(dispatch_map.keys())

# Easy to extend at runtime
dispatch_map[CustomType] = custom_handler

# Easy to override for testing
with patch.dict(dispatch_map, {str: mock_handler}):
    test_something()

Try doing that with an if-else chain.

6. Self-Documenting Type Contracts

The dispatch map is an explicit declaration of what types your function handles:

dispatch_map = {
    type(None): handle_none,
    list:       handle_list,
    str:        handle_str,
    int:        handle_int,
    float:      handle_float,
}

Compare to an if-else chain where you have to read through the entire function to understand the type contract.

When If-Else Still Makes Sense

Be honest about the tradeoffs:

Inheritance matters. isinstance() respects subclasses; type() doesn’t. If MyList(list) should match list, if-else with isinstance() handles it automatically. Dict dispatch needs explicit entries.

You have 2-3 cases. The cognitive overhead of a dispatch map isn’t worth it for simple switches - though as the benchmarks show, you’re not gaining performance either.

You need complex predicates. isinstance(x, (int, float)) or value-based conditions (if x > 0) don’t map cleanly to type dispatch.

The Hybrid Approach

For the inheritance problem, you can build the map dynamically:

def make_dispatch_map():
    handlers = {
        type(None): handle_none,
        list:       handle_list,
        str:        handle_str,
    }
    # Add subclass support
    for cls in [MyList, MyStr]:
        for base, handler in handlers.items():
            if issubclass(cls, base):
                handlers[cls] = handler
    return handlers

Or accept exact-type matching and document it as a feature, not a bug.

The Bottom Line

Dict dispatch gives you:

  • Predictable performance regardless of type distribution
  • O(1) scaling as you add cases
  • Equivalent speed to minimal if-else chains, while handling more types correctly
  • Cleaner code that separates routing from logic
  • Better testability through data-driven configuration

If-else chains give you:

  • Inheritance support via isinstance()
  • No performance advantage even in best-case scenarios
  • Familiar syntax that every Python developer recognizes

For type-based dispatch with 3+ cases, dict dispatch is the right default. It’s not just faster at scale - it’s never slower, handles more cases, and makes your code easier to maintain. There’s no downside.

Updated: