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.

Web App Reverse Checklist

Ready to Build Your Next Web App?

Get my Web App Reverse Checklist first ...


Software Engineering is often driven by fashion, but swimming with the current is rarely the best choice. In addition to knowing what to do, it's equally important to know what not to do. And this is precisely what my free Web App Reverse Checklist will help you with.

Subscribe below to get your free copy of my Reverse Checklist delivered to your inbox. Afterward, you can expect one weekly email on building resilient Web Applications using Python, JavaScript, and PostgreSQL.

By the way, it goes without saying that I'm not sharing your email address with anyone, and you're free to unsubscribe at any time. No spam. No commitments. No questions asked.

Continue Reading?

Here are a few more Articles for you ...


The Built-In sorted() Function

Python's sorted() function makes sorting easy and performant, guaranteeing stability and allowing descending order and custom mapping.

By Christoph Schiessl on Python

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

Christoph Schiessl

Hi, I'm Christoph Schiessl.

I help you build robust and fast Web Applications.


I'm available for hire as a freelance web developer, so you can take advantage of my more than a decade of experience working on many projects across several industries. Most of my clients are building web-based SaaS applications in a B2B context and depend on my expertise in various capacities.

More often than not, my involvement includes hands-on development work using technologies like Python, JavaScript, and PostgreSQL. Furthermore, if you already have an established team, I can support you as a technical product manager with a passion for simplifying complex processes. Lastly, I'm an avid writer and educator who takes pride in breaking technical concepts down into the simplest possible terms.