Function Overloading with the @overload Decorator

by Christoph Schiessl on Python

Python doesn't support function overloading in the true sense of the word, like Java, for instance, does. Nonetheless, the concept of overloading isn't foreign to Python because the standard library includes mechanisms to make functions look as if they were overloaded. This benefits third-party tools, such as IDEs, that can utilize the additional typing information that the "fake" overloading provides. The core functionality of overloading in Python is implemented in the typing module and specifically in the @overload decorator.

So, usually, when we define two functions with the same name, the first implementation is overwritten — it's no longer accessible.

Python 3.12.3 (main, Apr 20 2024, 16:22:09) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def foo(a: int) -> None:
...   print("First implementation:", a)
...
>>> def foo(a: int, b: int) -> None:
...   print("Second implementation:", a, b)
...
>>> foo(1) # foo() with one parameter has been overwritten
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo() missing 1 required positional argument: 'b'
>>> foo(1, 2)
Second implementation: 1 2

Since functions are just objects of the class function, we can assign them to different names using ordinary variables. For instance, you could do that to avoid losing access to the first implementation in the example above.

Python 3.12.3 (main, Apr 20 2024, 16:22:09) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def foo(a: int) -> None:
...   print("First implementation:", a)
...
>>> bar = foo
>>> def foo(a: int, b: int) -> None:
...   print("Second implementation:", a, b)
...
>>> foo(1) # foo() with one parameter has been overwritten
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo() missing 1 required positional argument: 'b'
>>> bar(1) # however, it's still accessible through its new name
First implementation: 1
>>> foo(1, 2)
Second implementation: 1 2

This is all interesting, for sure, but it doesn't accomplish anything as far as overloading goes. If you overload a function, all its variants must have the same name. That's the whole point of overloading, and that's where the @overload decorator comes into play.

Python 3.12.3 (main, Apr 20 2024, 16:22:09) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import overload
>>> @overload
... def foo(a: int) -> None: ...
...
>>> @overload
... def foo(a: int, b: int) -> None: ...
...
>>> def foo(*args):
...   if len(args) == 1: print("First implementation:", *args)
...   elif len(args) == 2: print("Second implementation:", *args)
...   else: raise TypeError(f"foo() takes 1 or 2 positional arguments but {len(args)} were given")
...
>>> foo() # too few positional parameters
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in foo
TypeError: foo() takes 1 or 2 positional arguments but 0 were given
>>> foo(1)
First implementation: 1
>>> foo(1, 2)
Second implementation: 1 2
>>> foo(1, 2, 3) # too many positional parameters
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in foo
TypeError: foo() takes 1 or 2 positional arguments but 3 were given

The logic we discussed before still applies if the @overload decorator is used. Later function definitions using the same name overwrite earlier function definitions, and the earlier ones are no longer accessible. The idea here is that the last function definition provides the actual implementation and must overwrite all previous function definitions. In other words, all function definitions, except for the last one, are only present for the sake of providing additional typing information.

The trick with assigning such functions to a different name doesn't accomplish anything because the bodies of the @overload-decorated functions are discarded and replaced with raise NotImplementedError. Observe:

Python 3.12.3 (main, Apr 20 2024, 16:22:09) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import overload
>>> @overload
... def foo(a: int) -> None:
...   print("This function is decorated with @overload")
...
>>> bar = foo
>>> def foo(*args):
...   print("This function is not decorated with @overload,")
...   print("and it received the following positional parameters:", *args)
...
>>> bar(1) # the NotImplementedError or coming from the @overload decorator!
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/cs/.asdf/installs/python/3.12.3/lib/python3.12/typing.py", line 2465, in _overload_dummy
    raise NotImplementedError(
NotImplementedError: You should not call an overloaded function. A series of @overload-decorated functions outside a stub module should always be followed by an implementation that is not @overload-ed.
>>> foo(1, 2)
This function is not decorated with @overload,
and it received the following positional parameters: 1 2

This is why it's recommended to use the Ellipsis object (i.e., ...) as a placeholder for the body of @overload-decorated functions. Adding an actual function body would be pointless because the @overload decorator replaces these bodies anyway.

If you have been paying attention so far, you may wonder now, what's the purpose of all this? The overloaded function above could also have been written with a default argument: def foo(a: int, b: int | None = None), which would have a cleaner solution.

However, there are cases where default arguments and even type variables don't cut it in the sense that they cannot precisely express the intended signature. Specifically, when you have to express a relationship between the type of one or more parameters with the type of the return value, you often hit the limit of expressiveness of Python's type system without @overload. For instance, take the following two functions:

def even(a: None) -> None: return None
def even(a: int) -> bool: return a % 2 == 0

As before, you cannot have two functions with the same name, but how do you merge the type signatures without losing any details? Think about it. The answer is, of course, that you can't because it's impossible.

def even(a: int | None) -> bool | None: ...

Your first idea was probably to use a signature like the one above, but it's a lossy conversion! First, combining an int parameter and a None return value is permitted. Second, combining a None parameter and a bool return value is also permitted. Both of these cases are only permitted now due to imprecisions introduced when the two function signatures were merged.

The only correct solution is to use function overloading:

Python 3.12.3 (main, Apr 21 2024, 14:06:33) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import overload
>>> @overload
... def even(a: None) -> None: ...
...
>>> @overload
... def even(a: int) -> bool: ...
...
>>> def even(a):
...   if a is not None:
...     return a % 2 == 0
...
>>> even(None)
>>> even(2)
True
>>> even(3)
False

Introducing the inspect.signature() function

It's not the main topic of this article, but I want to quickly introduce the inspect module and specifically the signature() function. So, the inspect module provides many powerful tools to introspect Python objects at runtime. One of these tools, the signature() function, takes a function object as a parameter and returns an object of the Signature class. This object represents the given function and is aware of all its type annotations. Here is a quick example:

Python 3.12.3 (main, Apr 21 2024, 14:06:33) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import inspect
>>> def foo(a: int, b: float | None, c) -> float | None: ...
...
>>> inspect.signature(foo)
<Signature (a: int, b: float | None, c) -> float | None>

Introspecting overloaded functions at runtime

I explained before that @overload-decorated functions are usually overwritten by one function that actually implements the functionality. Even though the overwritten functions are no longer accessible by name, they are not completely lost. In fact, you can still get the overwritten function objects with the help of the get_overloads() function.

from typing import overload

@overload
def even(a: None) -> None: ...

@overload
def even(a: int) -> bool: ...

def even(a):
    if a is not None:
        return a % 2 == 0

import inspect
from typing import get_overloads

for func in get_overloads(even):
    print(inspect.signature(func))

When you run this program, you get the following output:

(a: None) -> None
(a: int) -> bool

Clearing all registered overloads

Most programs don't need to introspect overloaded functions at runtime, but the typing information about these functions still takes up memory. If you are working in an environment where memory is not abundant, you may want to consider freeing up this memory. The typing module includes the clear_overloads() function to do that, which you can use to free the memory for all known overloaded functions.

Python 3.12.3 (main, Apr 21 2024, 14:06:33) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import overload, get_overloads, clear_overloads
>>> @overload
... def foo(a: int) -> int: ...
...
>>> def foo(a):
...   return a
...
>>> get_overloads(foo)
[<function foo at 0x7f944cc8c360>]
>>> clear_overloads()
>>> get_overloads(foo)
[]

Note that clear_overloads() has no parameters. If you want to free up memory like that, you can only do it globally but not selectively for specific functions.

Conclusion

Function overloading in Python is a tool to make the type annotation system more expressive, but it doesn't affect the program's runtime behavior most of the time. I say most of the time because, in theory, it's possible to write such programs. As you have seen, you can use introspection to access the signatures of overloaded functions, but this is not an actual use case for most applications.

I hope you found this article interesting and hope to see you again soon! As always, don't hesitate to reach out if you have any questions, and don't forget to subscribe to my mailing list to be notified when I publish new content.

Ready to Learn More Web Development?

Join my Mailing List to receive two articles per week.


I send two weekly emails on building performant and resilient Web Applications with Python, JavaScript and PostgreSQL. No spam. Unscubscribe at any time.

Continue Reading?

Here are a few more Articles for you ...


Function Definition Basics

Explore Python's function definition statement and discover its features with this series of articles. Get started with this simple introduction.

By Christoph Schiessl on Python

Function Definition with Keyword-Only Parameters

Learn about keyword-only parameters in Python, how to define them, and their interplay with position-only parameters.

By Christoph Schiessl on Python

Function Definition with Default Parameters

Learn about Python functions with default parameters. Understand how default parameters work and some essential restrictions and evaluation rules.

By Christoph Schiessl on Python

Christoph Schiessl

Christoph Schiessl

Independent Consultant + Full Stack Developer


If you hire me, you can rely on more than a decade of experience, which I have collected working on web applications for many clients across multiple industries. My involvement usually focuses on hands-on development work using various technologies like Python, JavaScript, PostgreSQL, or whichever technology we determine to be the best tool for the job. Furthermore, you can also depend on me in an advisory capacity to make educated technological choices for your backend and frontend teams. Lastly, I can help you transition to or improve your agile development processes.