Python Decorators: A Comprehensive Guide
This post is based on the helper project available on GitHub:
zacniewski/python-decorators-introPython decorators are one of the most powerful and elegant features of the language. They allow you to modify or extend the behavior of functions or classes without changing their source code. If you've ever used @app.route in Flask or @app.task in Celery, you've used decorators.
In this guide, we'll demystify decorators from the ground up, starting with the fundamental concept that makes them possible.
In Python, functions are first-class objects. This means you can assign them to variables, pass them as arguments to other functions, and even return them from functions.
def shout(word="yes"):
return word.capitalize() + "!"
# Assign function to a variable
scream = shout
print(scream())
# Output: Yes!
A decorator is simply a function that takes another function as an argument and returns a new function (a wrapper). The wrapper "wraps" the original function, allowing you to execute code before and after it runs.
def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper
def say_hello():
print("Hello!")
# Manual decoration
say_hello = my_decorator(say_hello)
say_hello()
# Output:
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.
Python provides a cleaner way to apply decorators using the @ symbol. This is just "syntactic sugar" for the manual assignment we did above.
@my_decorator
def say_hi():
print("Hi!")
# This is exactly the same as say_hi = my_decorator(say_hi)
You can apply multiple decorators to a single function. They are applied from the bottom up (the one closest to the function runs first).
def bread(func):
def wrapper():
print("</''''''\>")
func()
print("<\______/>")
return wrapper
def ingredients(func):
def wrapper():
print("#tomatoes#")
func()
print("~salad~")
return wrapper
@bread
@ingredients
def sandwich():
print("--ham--")
sandwich()
# Output:
# </''''''\>
# #tomatoes#
# --ham--
# ~salad~
# <\______/>
To decorate functions that take arguments, your wrapper needs to accept those arguments and pass them along. Using *args and **kwargs ensures your decorator works with any number of arguments.
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args}")
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
return a + b
print(add(5, 10))
Sometimes you want to pass arguments to the decorator itself. This requires an extra level of nesting: a "decorator maker" that returns the actual decorator.
def repeat(num_times):
def decorator_repeat(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("Alice")
When you wrap a function, the new function (the wrapper) replaces the old one. This means metadata like the function's name and docstring are lost. To fix this, Python provides functools.wraps.
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
"""I am the wrapper docstring"""
return func(*args, **kwargs)
return wrapper
@my_decorator
def complex_logic():
"""I am the original docstring"""
pass
print(complex_logic.__name__) # Outputs: complex_logic (instead of 'wrapper')
print(complex_logic.__doc__) # Outputs: I am the original docstring
Summary
- Decorators are functions that modify other functions.
- They use closures to remember the original function.
- The
@decoratorsyntax is syntactic sugar. - Always use
functools.wrapsto preserve metadata.