What Are Decorators?
A decorator is a function that takes another function as an argument and extends its behavior without explicitly modifying it. If you have used @property or @staticmethod, you have already used decorators.
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before the function call")
result = func(*args, **kwargs)
print("After the function call")
return result
return wrapper
@my_decorator
def say_hello(name):
print(f"Hello, {name}!")
say_hello("World")
Output:
Before the function call
Hello, World!
After the function call
Why Use Decorators?
Decorators are powerful because they let you add behavior to functions and classes without modifying their source code. Common use cases include:
- Logging — Track when functions are called and with what arguments
- Authentication — Check permissions before executing sensitive operations
- Caching — Store results of expensive computations with
@functools.lru_cache - Timing — Measure function execution time for profiling
- Retry logic — Automatically retry failed operations
Building Your First Decorator
Let us start with the simplest possible decorator — one that logs every time a function is called.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 5)
The @ Syntax Is Just Sugar
Writing @log_calls above a function definition is identical to:
def add(a, b):
return a + b
add = log_calls(add)
The @ syntax is purely convenience. Understanding this equivalence is key to understanding decorators.
Preserving Function Metadata
One problem with the simple wrapper above: add.__name__ now returns "wrapper" instead of "add". Fix this with functools.wraps:
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Always use @wraps(func) in your decorators. It preserves __name__, __doc__, and other metadata from the original function.
Decorators with Arguments
Sometimes you want to pass arguments to the decorator itself. This requires an extra layer of nesting:
from functools import wraps
def repeat(n):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("World")
# Prints "Hello, World!" three times
The pattern is: repeat(3) returns decorator, which is then applied to greet.
Class-Based Decorators
You can also implement decorators as classes using __call__:
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.func.__name__} has been called {self.count} times")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("Hi!")
say_hi()
say_hi()
Class-based decorators are useful when you need the decorator to maintain state between calls.
Real-World Example: Timing Decorator
Here is a practical decorator you might actually use:
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "done"
slow_function() # slow_function took 1.0012s
Key Takeaways
- Decorators wrap functions to extend their behavior
- The
@decoratorsyntax is sugar forfunc = decorator(func) - Always use
@functools.wrapsto preserve metadata - Decorators with arguments need an extra nesting level
- Class-based decorators use
__call__and can maintain state