What are Decorators in Python and how do they work?
If you are new to Python, or have been using it for a while and want to dig deeper into its most elegant and powerful features, decorators are a top candidate. In Python, decorators are a powerful way to modify or enhance the behaviour of a function without changing its original code. Because of this, decorators are known as wrappers that decorate functions to provide extra functionality.
Why do we need decorators?
Think of a scenario where you were decorating something, say a Christmas tree. You add extra lights, garlands, ribbons, etc. But underneath, the original Christmas tree is still there, and the decorated result is still a Christmas tree. Similarly, you can give additional functionality to existing functions and classes via decorators. It’s a form of metaprogramming - code that manipulates code. Before understanding decorators, we need to better understand functions.
Functions are first-class objects
It is important to understand that in Python, functions are known as first-class objects. What this means is that, similar to other objects, you can pass functions as arguments, return them from other functions, and even assign them to variables.
- To see this, let’s define a simple function as below.
def greet(name): return f"Hello {name}!" - Now the most common way of using the above function is to simply invoke it, like below.
result = greet("John") # result = Hello John! - But since functions are first-class objects, we can use functions as arguments.
say_hello = greet # Assigning function to a variable print(say_hello("John")) # Output: Hello, John! - We can return the greet function from another function.
def foo():
return greet # Returning greet function from another function
greet_from_foo = foo()
print(greet_from_foo("John")) # Output: Hello, John!
- Or we can invoke the greet function from within another function.
def execute_function(func, value):
return greet(value)
print(execute_function(greet, "Bob")) # Hello, Bob!
Understanding the above simple concepts makes it possible to create decorators in Python.
Writing a basic decorator from scratch
Let’s define a simple decorator that times how long a function takes to run.
import time
def my_timer_decorator(func):
"""A decorator that times function execution"""
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs) # Call the original function
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
return result
return wrapper
# Let's define our function against which we need to do timing
def my_slow_function():
time.sleep(1)
return "Done!"
Now, there are two ways to use the above decorator. The first way is to treat my_timer_decorator as a function that takes another function as input, as below.
my_slow_function = my_timer_decorator(my_slow_function) # Notice my_slow_function overrides the original my_slow_function
my_slow_function() # now every time my_slow_function is invoked, it calculates the time, ex: my_slow_function took 1.0001 seconds
If you have been following along, you’ll see that after overriding my_slow_function as my_slow_function = my_timer_decorator(my_slow_function), it will call the function wrapped by the my_timer_decorator decorator. This still enables us to call my_slow_function from within the wrapper, with an additional timing feature. Let’s dissect the my_timer_decorator decorator line by line to better understand what each line does.
# L1 def my_timer_decorator(func):
# L2 """A decorator that times function execution"""
# L3 def wrapper(*args, **kwargs):
# L4 start_time = time.time()
# L5 result = func(*args, **kwargs)
# L6 end_time = time.time()
# L7 print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
# L8 return result
# L9 return wrapper
- L1:
my_timer_decoratoris the function that accepts other functions as input to be decorated - L2: A docstring describing what this decorator does
- L3:
wrapperis a child function that handles the positional arguments (args) and named arguments (kwargs) of func. If you’re not sure what positional and named arguments mean in Python, here’s a quick refresher.
Positional Arguments
These are values passed to a function in a specific order. Python assigns these values to the corresponding parameters based strictly on their sequence. If you change their order, it affects the function’s logic. Example:
def take_power(*args):
if len(args) != 2:
return TypeError("Invalid number of arguments")
return args[0]**args[1]
print(take_power(2, 3)) # gives 2**3 = 8
print(take_power(3, 2)) # swap the order gives 3**2 = 9
Named (Keyword) Arguments
These are passed by explicitly specifying the parameter name, followed by an equals sign (=) and the value. The advantage is that you can change their order.
def my_fraction(**kwargs):
if kwargs["denominator"] == 0:
return "Error: Cannot divide by zero!"
return kwargs["numerator"]/kwargs["denominator"]
print(my_fraction(numerator=2, denominator=10)) # prints 0.2
print(my_fraction(denominator=10, numerator=2)) # swap the position, still prints 0.2
- L4: Store the time before calling the decorated func - you can add as much custom logic as you like between L4 and L5
- L5: Invoke the original function with its original arguments and store its result
- L6: Store the time after calling the function - you can add as much custom logic as you like between L6 and L8
- L7: Print the total time taken for the decorated function
- L8: Return the result of the original function to the decorator
- L9: Return the wrapper function to the original caller
Introducing the @ syntax
As I mentioned before, there are two ways to use a decorator, and you have already seen the first way, using it directly as a function. Python provides a shorthand for applying decorators using the @ symbol. As an example, you can apply the same timing decorator as below, and it looks pretty neat now!
@my_timer_decorator
def my_slow_function():
time.sleep(1)
return "Done!"
Once decorated, every time you call my_slow_function(), what you get is not the original function, but the same function decorated with that additional functionality to measure the timing! How cool is that?
Decorators with arguments
If you recall why we need decorators in the first place, it’s because they can provide additional features to existing functions without having to modify them. To do that, decorators sometimes need additional arguments. In such scenarios, our decorator needs another level of nesting. As an example, let’s imagine we need a decorator to time a function, but this time it needs to repeat the function a configurable number of times. Here is a sample.
def my_repeat_timer(times):
"""This is a decorator that repeats a function a number of times and measures its execution time"""
def func_consuming_decorated_func(func):
def func_consuming_decorated_func_arguments(*args, **kwargs):
start_time = time.time()
results = []
for _ in range(times):
results.extend(func(*args, **kwargs)) # Call the original function
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.4f} seconds to run {times} times")
return results
return func_consuming_decorated_func_arguments
return func_consuming_decorated_func
If you take a closer look at each nested function in the above decorator, each has a specific purpose. Each function returns its child function at its level. Let’s take a closer look at each one.
- my_repeat_timer(times): this is the decorator itself, and it takes times as a parameter. It returns the func_consuming_decorated_func function
- func_consuming_decorated_func(func): this is the first nested function, and it takes the decorated function as a parameter. It returns the func_consuming_decorated_func_arguments function
- func_consuming_decorated_func_arguments(*args, **kwargs): this is the second nested function, and it takes the parameters of the decorated function as positional and named parameters. In addition, it executes the input function the number of times specified by the decorator, and returns the aggregated results from the original function.
Let’s see this new decorator in action. As you’d expect, it uses the @ syntactic sugar, since that’s much cleaner and easier to use.
@my_repeat_timer(times=2)
def my_slow_function():
"""This is my_slow_function"""
time.sleep(1)
return "Done!"
# Having decorated, this will run my_slow_function 2 times each time it is called.
res = my_slow_function() # my_slow_function took 2.0013 seconds to run 2 times
print(res) # ["Done!", "Done!"]
Preserving function metadata
When creating decorators of our own, we need to make sure the metadata of the decorated function, such as __name__, __doc__, and __module__, is preserved by the decorator. Let’s see what we get when we check now.
print(my_slow_function.__name__) # func_consuming_decorated_func_arguments
print(my_slow_function.__doc__) # None
This is because, if you recall the first way of using decorators, this new decorator is equivalent to:
my_slow_function = my_repeat_timer(times=2)(my_slow_function)
What is returned by my_repeat_timer(times=2)(my_slow_function) is the func_consuming_decorated_func_arguments function (the second level of nesting), and that’s why, if you access my_slow_function.__name__, it appears as func_consuming_decorated_func_arguments.
To avoid this, there is a special built-in function we can use: from functools import wraps. The modified decorator looks like below.
from functools import wraps
def my_repeat_timer(times):
"""This is a decorator that repeats a function a number of times and measures its execution time"""
def func_consuming_decorated_func(func):
@wraps(func) # This preserves the metadata of the func function
def func_consuming_decorated_func_arguments(*args, **kwargs):
start_time = time.time()
results = []
for _ in range(times):
results.extend(func(*args, **kwargs)) # Call the original function
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.4f} seconds to run {times} times")
return results
return func_consuming_decorated_func_arguments
return func_consuming_decorated_func
Now, if you apply this updated decorator, you can see that the metadata of the function is preserved!
@my_repeat_timer(times=2)
def my_slow_function():
"""This is my_slow_function"""
time.sleep(1)
return "Done!"
print(my_slow_function.__name__) # my_slow_function
print(my_slow_function.__doc__) # This is my_slow_function
Class-based decorators
Decorators can also be classes. Let’s create an example decorator to see this in action. Please note this decorator does not accept arguments.
from functools import wraps
class CountCalls:
def __init__(self, func):
wraps(func)(self) # this preserves the metadata of the decorated function
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
"""This is a function to say hello"""
print("Hello!")
say_hello() # Call 1 to say_hello \n Hello!
say_hello() # Call 2 to say_hello \n Hello!
print(say_hello.__doc__) # This is a function to say hello
print(say_hello.__name__) # say_hello
print(say_hello.__module__) #__main__
# Let's decorate another function with the same decorator
@CountCalls
def say_bye():
"""This is a function to say bye"""
print("Bye!")
say_bye() # Call 1 to say_bye \n Bye!
say_bye() # Call 2 to say_bye \n Bye!
print(say_bye.__doc__) # This is a function to say bye
print(say_bye.__name__) # say_bye
print(say_bye.__module__) #__main__
As you can see, the name of the class CountCalls becomes the name of the decorator, and it can individually count the number of times a decorated function is invoked, while preserving their metadata.
What is the __call__ function above?
__call__ is a special dunder (short for double underscore) method that allows an instance of a class to be called like a function. Basically, if you have a class named Foo, the usual approach is to create an object of Foo, like f = Foo(), and then call functions on f, such as f.do_something(). But if the Foo class has a __call__ function defined, you can simply use the object f itself, as if it were a function. Let’s see another example.
class Greeter:
def __call__(self, name):
print(f"Hello, {name}!")
g = Greeter()
g("Alice") # Hello, Alice
# The object g itself now acts like a method!
When you call g(“Alice”), what happens behind the scenes is that g.call(“Alice”) gets invoked.
Similarly, when you have a class-based decorator, every time you use that decorator, the __init__ of the class is invoked. And every time the decorated function is called, the __call__ of the class gets invoked. I invite you to test this on your own by adding some print statements to see when each function is called.
Real-world applications of decorators
In Python, decorators are everywhere! Python has some common built-in decorators, such as:
- @staticmethod – defines a static method in a class
- @classmethod – defines a class method
- @property – makes a method behave like an attribute
I will create a separate blog post on built-in decorators.
And if you have used frameworks in Python, like Flask or Django, decorators are used everywhere, for example:
- @app.route(‘/home’)
- @login_required
- @permission_required
In test frameworks like pytest or unittest, you can barely code without using decorators, ex:
- @unittest.mock.patch
- @unittest.skip
- @pytest.mark.parametrize
- @pytest.mark.xfail
Thank you!
This marks the end of this blog post, and I hope you have learned something new and feel more confident using and creating your own decorators in your Python projects! I wish you all the very best, and happy coding!