Decorators are one of Python's most powerful features, yet many developers find them confusing at first. In this guide, I'll break down decorators step by step with practical examples you can use in your projects right away.
What Are Decorators?
At their core, decorators are functions that modify the behavior of other functions. They're a form of metaprogramming that allows you to add functionality to existing code without modifying it directly.
Your First Decorator
def my_decorator(func):
def wrapper():
print("Something before the function")
func()
print("Something after the function")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
# Call the decorated function
say_hello()
Output:
Something before the function
Hello!
Something after the function
Decorators with Arguments
Real functions often take arguments. Here's how to create a decorator that works with functions that accept any number of arguments.
Handling Function Arguments
import functools
def log_calls(func):
@functools.wraps(func) # Preserves function metadata
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):
"""Add two numbers."""
return a + b
@log_calls
def greet(name, greeting="Hello"):
"""Greet someone."""
return f"{greeting}, {name}!"
# Test the decorated functions
add(3, 5)
greet("Alice", greeting="Hi")
Output:
Calling add with args=(3, 5), kwargs={}
add returned 8
Calling greet with args=('Alice',), kwargs={'greeting': 'Hi'}
greet returned Hi, Alice!
Pro Tip: Always use @functools.wraps in your decorators.
It preserves the original function's name, docstring, and other metadata.
Decorator Factory Pattern
Sometimes you need decorators that accept their own arguments. This is where the decorator factory pattern comes in.
Creating Configurable Decorators
import functools
import time
def retry(max_attempts=3, delay=1):
"""Decorator factory that retries a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
print(f"Attempt {attempt} failed: {e}")
if attempt < max_attempts:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
# Usage with arguments
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
# Simulated API call that might fail
import random
if random.random() < 0.7: # 70% chance of failure
raise ConnectionError("Network error")
return {"data": "success"}
# The decorator will automatically retry on failure
result = fetch_data("https://api.example.com")
Practical Examples
Timing Decorator
Measure how long a function takes to execute—useful for performance optimization.
Execution Timer
import functools
import time
def timer(func):
"""Measure execution time of a function."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} executed in {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "Done"
slow_function()
# Output: slow_function executed in 1.0012 seconds
Memoization (Caching)
Cache function results to avoid redundant computations. Python has a built-in solution, but understanding how to build one is valuable.
Simple Cache Implementation
import functools
def memoize(func):
"""Cache function results for repeated calls."""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.cache = cache # Expose cache for debugging
return wrapper
@memoize
def fibonacci(n):
"""Calculate nth Fibonacci number (recursive)."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Now it's fast even for large numbers
print(fibonacci(100)) # 354224848179261915075
# Or just use Python's built-in:
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci_builtin(n):
if n < 2:
return n
return fibonacci_builtin(n - 1) + fibonacci_builtin(n - 2)
Class Decorators
Decorators can also be applied to classes, and you can use classes as decorators.
Class-based Decorator
class CountCalls:
"""Decorator class that counts function calls."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("Hi!")
say_hi() # Call 1 of say_hi
say_hi() # Call 2 of say_hi
say_hi() # Call 3 of say_hi
print(f"Total calls: {say_hi.count}") # Total calls: 3
Key Takeaways
- Decorators wrap functions to add behavior without modifying the original code
- Use
*argsand**kwargsto handle any function signature - Always use
@functools.wrapsto preserve function metadata - Decorator factories let you create decorators that accept arguments
- Python's
@lru_cacheis a powerful built-in for memoization
Decorators are used extensively in popular frameworks like Flask (@app.route),
Django (@login_required), and pytest (@pytest.fixture).
Mastering them will make you more effective at reading and writing Pythonic code.