Skip to main content

Modern multiple dispatch for Python 3.12+ with explicit ambiguity handling, callable guards, and method fallback.

Project description

PyPI version PyPI Python versions CI status Publish status License

multimethods

Multiple dispatch for modern Python.
Dispatch on what actually matters. Stay explicit under ambiguity. Keep methods feeling like methods.

Fast exact-type hot paths, callable guards, keyword-aware calls, and class fallback that still behaves like Python.

The Pitch

Most Python dispatch tools stop at "pick an implementation based on the first argument".

That is useful, but a lot of real systems need more:

  • a compiler that dispatches on (node, backend)
  • a renderer that dispatches on (value, target_format)
  • a pricing engine that dispatches on (instrument, market)
  • a rules engine that dispatches on type first, then value constraints
  • a class hierarchy where subclasses specialize a few cases without breaking normal fallback

That is where multimethods lives.

It gives you:

  • multiple dispatch across more than one argument
  • explicit ambiguity errors instead of silent guesswork
  • callable guards instead of string eval
  • keyword-call support through canonical signature binding
  • @staticmethod and @classmethod support
  • ordinary MRO fallback when a subclass only overloads part of the surface
  • a fast exact-type cache for repeated hot-path calls

Why It Feels Good

You want multimethods gives you
Dispatch on more than one input axis native multi-argument dispatch
Refine a type case by value guard= and Annotated[..., where(...)]
Predictable semantics specificity rules, then guard rules, then explicit priority=
No hidden "best effort" guessing AmbiguousDispatchError when signatures are incomparable
Method overloads that still behave like Python methods bound methods, staticmethods, classmethods, and MRO fallback
Speed where it counts exact winner cache for pure type-only hot paths

Installation

python -m pip install multimethods

Requires Python 3.12+.

A 30-Second Taste

This is the shape of the library:

from multimethods import multimethod


@multimethod
def render(value: object, target: object) -> str:
    return "fallback"


@render.register
def _(value: int, target: str) -> str:
    return f"int:{value}:{target}"


@render.register
def _(value: str, target: str) -> str:
    return f"str:{value}:{target}"


assert render(3, "cli") == "int:3:cli"
assert render("hello", "cli") == "str:hello:cli"
assert render(3.14, "cli") == "fallback"

The mental model is simple:

  1. write a normal function
  2. register overloads
  3. let runtime types decide which one wins
  4. get an explicit error if the winner is genuinely unclear

Examples

1. Dispatch On Two Real Axes

A lot of dispatch problems are naturally two-dimensional.

from dataclasses import dataclass

from multimethods import multimethod


@dataclass
class JSON:
    pass


@dataclass
class CSV:
    pass


@dataclass
class User:
    name: str
    email: str


@dataclass
class Invoice:
    total: int
    currency: str


@multimethod
def dump(value: User, target: JSON) -> str:
    return f'{{"name": "{value.name}", "email": "{value.email}"}}'


@dump.register
def _(value: User, target: CSV) -> str:
    return f"{value.name},{value.email}"


@dump.register
def _(value: Invoice, target: JSON) -> str:
    return f'{{"total": {value.total}, "currency": "{value.currency}"}}'

That reads like the problem statement. No manual matrix dispatch table, no nested if isinstance(...) ladders.

2. Refine A Type Match With A Guard

Sometimes "type" is not enough. You want int, but only in a specific value range.

from multimethods import multimethod


@multimethod(int, guard=lambda status: status == 429)
def retry_policy(status: int) -> str:
    return "retry-with-backoff"


@retry_policy.register(int, guard=lambda status: 500 <= status < 600)
def _(status: int) -> str:
    return "retry"


@retry_policy.register(int)
def _(status: int) -> str:
    return "do-not-retry"


assert retry_policy(429) == "retry-with-backoff"
assert retry_policy(503) == "retry"
assert retry_policy(404) == "do-not-retry"

This is value-aware dispatch without resorting to string expressions or a second ad hoc rule system.

3. Dispatch On A Subset Of Parameters

Sometimes only the first one or two arguments should participate in dispatch, while the rest are normal parameters.

from multimethods import multimethod


@multimethod(int)
def parse(value, *, base=10):
    return value


@parse.register(str)
def _(value, *, base=10):
    return int(value, base=base)


assert parse(12) == 12
assert parse("1111", base=2) == 15

That gives you a clean way to say: "dispatch on the input shape, but keep configuration keyword-only".

4. Methods Still Behave Like Methods

Subclass-specific overloads should not destroy ordinary class fallback.

from multimethods import multimethod


class BaseFormatter:
    def format(self, value):
        return str(value)


class ReportFormatter(BaseFormatter):
    @multimethod
    def format(self, value: int):
        return f"{value:,}"


formatter = ReportFormatter()

assert formatter.format(1250000) == "1,250,000"
assert formatter.format("raw") == "raw"

When no local overload matches, multimethods walks the class MRO and calls the next attribute with the same name. That makes partial specialization practical.

5. Per-Parameter Predicates With Annotated

For parameter-local rules, where(...) composes cleanly with Annotated.

from typing import Annotated

from multimethods import multimethod, where


@multimethod
def bucket(x: Annotated[int, where(lambda x: x >= 0)]) -> str:
    return "non-negative"


@bucket.register
def _(x: int) -> str:
    return "negative"


assert bucket(10) == "non-negative"
assert bucket(-3) == "negative"

Ambiguity Is A Feature, Not A Failure

Many dispatch systems quietly choose one candidate when two overloads are both plausible. That makes debugging miserable.

multimethods does not do that.

from multimethods import AmbiguousDispatchError, multimethod


@multimethod
def collide(x: int, y: object):
    return "left"


@collide.register
def _(x: object, y: int):
    return "right"


try:
    collide(1, 1)
except AmbiguousDispatchError:
    print("good: the dispatcher refused to guess")

If you really want one side to win, make that decision explicit with priority=:

@multimethod(priority=10)
def choose(x: int, y: object):
    return "left"


@choose.register(priority=1)
def _(x: object, y: int):
    return "right"

Resolution Rules

Dispatch works in this order:

  1. Find overloads whose type constraints match the runtime dispatch arguments.
  2. Remove strictly less specific candidates.
  3. Evaluate parameter guards and callable guards.
  4. Prefer guarded candidates over unguarded candidates with identical type constraints.
  5. Break remaining ties with priority=.
  6. Raise AmbiguousDispatchError if multiple winners remain.
  7. If this is a bound method call and no local overload matches, walk the class MRO and call the next attribute with the same name.

Keyword-only parameters and variadic parameters are allowed, but they never participate in dispatch.

Supported Today

  • plain classes
  • abstract base classes
  • typing.Any
  • unions of supported classes, including int | str
  • typing.Annotated[T, where(...)]
  • annotation-based registration
  • explicit decorator registration like @multimethod(int, str)

What This Is Optimized For

multimethods is built for code that wants richer semantics than a minimal dispatch helper, while still caring about performance.

The implementation currently optimizes:

  • repeated exact-type calls
  • pure type-only overload sets
  • positional hot paths that can bypass inspect.Signature.bind

On the included local benchmark, warmed exact-type calls are currently faster than the older multimethod / multidispatch implementations, roughly in the same band as plum, and still behind multipledispatch and especially ovld on raw microbenchmarks.

That is the honest position today:

  • strong semantics
  • practical ergonomics
  • competitive hot-path speed
  • still room to push harder on generated fast paths

You can run the benchmark yourself:

python benchmarks/compare_dispatch.py

When To Reach For It

multimethods is a good fit when your codebase has:

  • AST or IR transforms
  • serializers and renderers with multiple target formats
  • geometry, simulation, or collision-style double dispatch
  • pricing or analytics logic driven by pairs of domain objects
  • plugin systems where both the payload and backend matter
  • business rules that are cleaner as overloads than as branching trees

If a plain if statement is enough, use a plain if statement.

If first-argument dispatch is enough, functools.singledispatch is still excellent.

This library is for the cases after that.

Comparison with alternatives

Python has several dispatch options. multimethods targets the gap after functools.singledispatch: multi-argument problems that still need predictable semantics and ordinary method behavior.

Library Multi-arg Zero deps Explicit ambiguity Callable guards OOP MRO fallback
functools.singledispatch no yes (stdlib) N/A no no
multimethod (coady) limited yes partial no no
multipledispatch yes yes often wrong string eval no
plum yes no (beartype) yes via beartype yes
ovld yes varies varies pattern-style varies
multimethods yes yes yes yes yes

When singledispatch is enough: one dispatch axis, stdlib-only, excellent ergonomics. Stay there.

When multipledispatch is tempting: broad adoption and raw speed, but ambiguity handling and OOP fallback are weaker for production rule systems.

When plum is tempting: richest typing integration (parametric types, beartype-powered hints). Choose it when you want that breadth and accept the dependency.

When ovld is tempting: fastest microbenchmarks and a pattern-matching style API. Choose it when codegen speed matters more than Julia-like ambiguity rules.

When multimethods fits: you want zero-dependency multiple dispatch with explicit ambiguity errors, callable guards, and subclass MRO fallback without adopting a larger typing framework.

Introspection API: .dispatch(), .registry, .copy()

Every MultiMethod exposes three helpers for debugging, tooling, and extension.

.dispatch(*args, **kwargs)

Resolve the winning overload without calling it. Returns the underlying function.

from multimethods import multimethod


@multimethod
def convert(value: object) -> str:
    return str(value)


@convert.register
def _(value: int) -> str:
    return f"int:{value}"


fn = convert.dispatch(3)
assert fn is convert.registry[1].function
assert fn(3) == "int:3"
assert convert(3) == "int:3"

.registry

Read-only view of registered overloads after pending forward references resolve.

assert len(convert.registry) == 2
assert all(hasattr(entry, "constraints") for entry in convert.registry)

.copy()

Clone the dispatcher and its overload table. Caches start empty on the clone.

clone = convert.copy()
assert clone is not convert
assert len(clone.registry) == len(convert.registry)
assert clone(3) == convert(3)

Type checker integration

Runtime dispatch is dynamic; static type checkers need explicit stubs. Pair @multimethod overloads with typing.overload declarations that share the same name and parameter shapes.

from typing import overload

from multimethods import multimethod


@overload
def stringify(value: int) -> str: ...


@overload
def stringify(value: str) -> str: ...


@multimethod
def stringify(value: object) -> str:
    return str(value)


@stringify.register
def _(value: int) -> str:
    return f"int:{value}"


@stringify.register
def _(value: str) -> str:
    return f"str:{value}"

mypy and pyright use the @overload stubs for call-site checking; Python uses the @multimethod registrations at runtime. Keep stub signatures aligned with the dispatch parameters of each registered overload.

Unsupported runtime annotations such as Literal[...], list[int], and Protocol are rejected at registration time. Use plain classes plus guard= or Annotated[..., where(...)] for value refinement instead.

Development

python -m pip install -e .[dev]
python -m ruff check .
python -m pytest -q
python -m build
python benchmarks/compare_dispatch.py

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

multimethods-2.1.0.tar.gz (22.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

multimethods-2.1.0-py3-none-any.whl (14.5 kB view details)

Uploaded Python 3

File details

Details for the file multimethods-2.1.0.tar.gz.

File metadata

  • Download URL: multimethods-2.1.0.tar.gz
  • Upload date:
  • Size: 22.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for multimethods-2.1.0.tar.gz
Algorithm Hash digest
SHA256 c90e03f37e7d6d0ab6968f1f939e93fec8f6d4305dc2cfab62945b1f6539e20d
MD5 f554ce62d8e7701ecd1a6d653d813eae
BLAKE2b-256 073b0a13f12e8f631c533613a9f3636cbce030655c9b564146f7fa56576fb9e8

See more details on using hashes here.

Provenance

The following attestation bundles were made for multimethods-2.1.0.tar.gz:

Publisher: pypi-publish.yml on r0k3/multimethods

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file multimethods-2.1.0-py3-none-any.whl.

File metadata

  • Download URL: multimethods-2.1.0-py3-none-any.whl
  • Upload date:
  • Size: 14.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for multimethods-2.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 572185135f68df371b28b2401de4668713313093ea7780d8f7918a59f47a4536
MD5 3b5b8cc9c53f6b6403e892c35ca81544
BLAKE2b-256 ec9c4f9e7081b30f37aa28733d267ab6e7436d47efac2c694c599da3e58b2ad8

See more details on using hashes here.

Provenance

The following attestation bundles were made for multimethods-2.1.0-py3-none-any.whl:

Publisher: pypi-publish.yml on r0k3/multimethods

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page