Skip to content

Decorators - Advanced Python 13

A decorator is a function that takes another function and extends the behavior of this function without explicitly modifying it.


A decorator is a function that takes another function and extends the behavior of this function without explicitly modifying it. It is a very powerful tool that allows to add new functionality to an existing function.

There are 2 kinds of decorators:

  • Function decoratos
  • Class decorators

A function is decorated with the @ symbol:

@my_decorator
def my_function():
    pass

Function decorators

In order to understand the decorator pattern, we have to understand that functions in Python are first class objects, which means that – like any other object – they can be defined inside another function, passed as argument to another function, or returned from other functions.

A decorator is a function that takes another function as argument, wraps its behaviour inside an inner function, and returns the wrapped function. As a consequence, the decorated function now has extended functionality!

# A decorator function takes another function as argument, wraps its behaviour inside
# an inner function, and returns the wrapped function.
def start_end_decorator(func):

    def wrapper():
        print('Start')
        func()
        print('End')
    return wrapper

def print_name():
    print('Alex')

print_name()

print()

# Now wrap the function by passing it as argument to the decorator function
# and asign it to itself -> Our function has extended behaviour!
print_name = start_end_decorator(print_name)
print_name()
Alex

Start
Alex
End

The decorator syntax

Instead of wrapping our function and asigning it to itself, we can achieve the same thing simply by decorating our function with an @.

@start_end_decorator
def print_name():
    print('Alex')

print_name()
Start
Alex
End

What about function arguments

If our function has input arguments and we try to wrap it with our decorator above, it will raise a TypeError since we have to call our function inside the wrapper with this arguments, too. However, we can fix this by using *args and **kwargs in the inner function:

def start_end_decorator_2(func):

    def wrapper(*args, **kwargs):
        print('Start')
        func(*args, **kwargs)
        print('End')
    return wrapper

@start_end_decorator_2
def add_5(x):
    return x + 5

result = add_5(10)
print(result)
Start
End
None

Return values

Note that in the example above, we do not get the result back, so as next step we also have to return the value from our inner function:

def start_end_decorator_3(func):

    def wrapper(*args, **kwargs):
        print('Start')
        result = func(*args, **kwargs)
        print('End')
        return result
    return wrapper

@start_end_decorator_3
def add_5(x):
    return x + 5

result = add_5(10)
print(result)
Start
End
15

What about the function identity?

If we have a look at the name of our decorated function, and inspect it with the built-in help function, we notice that Python thinks our function is now the wrapped inner function of the decorator function.

print(add_5.__name__)
help(add_5)
wrapper
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

To fix this, use the functools.wraps decorator, which will preserve the information about the original function. This is helpful for introspection purposes, i.e. the ability of an object to know about its own attributes at runtime:

import functools
def start_end_decorator_4(func):

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print('Start')
        result = func(*args, **kwargs)
        print('End')
        return result
    return wrapper

@start_end_decorator_4
def add_5(x):
    return x + 5
result = add_5(10)
print(result)
print(add_5.__name__)
help(add_5)
Start
End
15
add_5
Help on function add_5 in module __main__:

add_5(x)  

The final template for own decorators

Now that we have all parts, our template for any decorator looks like this:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Do something before
        result = func(*args, **kwargs)
        # Do something after
        return result
    return wrapper

Decorator function arguments

Note that functools.wraps is a decorator that takes an argument for itself. We can think of this as 2 inner functions, so an inner function within an inner function. To make this clearer, we look at another example: A repeat decorator that takes a number as input. Within this function, we have the actual decorator function that wraps our function and extends its behaviour within another inner function. In this case, it repeats the input function the given number of times.

def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet('Alex')
Hello Alex
Hello Alex
Hello Alex

Nested Decorators

We can apply several decorators to a function by stacking them on top of each other. The decorators are being executed in the order they are listed.

# a decorator function that prints debug information about the wrapped function
def debug(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Calling {func.__name__}({signature})")
        result = func(*args, **kwargs)
        print(f"{func.__name__!r} returned {result!r}")
        return result
    return wrapper

@debug
@start_end_decorator_4
def say_hello(name):
    greeting = f'Hello {name}'
    print(greeting)
    return greeting

# now `debug` is executed first and calls `@start_end_decorator_4`, which then calls `say_hello`
say_hello(name='Alex')
Calling say_hello(name='Alex')
Start
Hello Alex
End
'say_hello' returned 'Hello Alex'

Class decorators

We can also use a class as a decorator. Therefore, we have to implement the __call__() method to make our object callable. Class decorators are typically used to maintain a state, e.g. here we keep track of the number of times our function is executed. The __call__ method does essentially the same thing as the wrapper() method we have seen earlier. It adds some functionality, executes the function, and returns its result. Note that here we use functools.update_wrapper() instead of functools.wraps to preserve the information about our function.

import functools

class CountCalls:
    # the init needs to have the func as argument and stores it
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num_calls = 0

    # extend functionality, execute function, and return the result
    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} of {self.func.__name__!r}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello(num):
    print("Hello!")

say_hello(5)
say_hello(5)
Call 1 of 'say_hello'
Hello!
Call 2 of 'say_hello'
Hello!

Some typical use cases

  • Use a timer decorator to calculate the execution time of a function
  • Use a debug decorator to print out some more information about the called function and its arguments
  • Use a check decorator to check if the arguments fulfill some requirements and adapt the bevaviour accordingly
  • Register functions (plugins)
  • Slow down code with time.sleep() to check network behaviour
  • Cache the return values for memoization (https://en.wikipedia.org/wiki/Memoization)
  • Add information or update a state

FREE VS Code / PyCharm Extensions I Use

✅ Write cleaner code with Sourcery, instant refactoring suggestions: Link*


Python Problem-Solving Bootcamp

🚀 Solve 42 programming puzzles over the course of 21 days: Link*

* These are affiliate link. By clicking on it you will not have any additional costs. Instead, you will support my project. Thank you! 🙏