Python Decorators: Complete Guide with Examples
Decorators are a powerful Python feature that allows you to modify or enhance functions and methods without directly changing their code. At their core, decorators are simply functions that take...
Key Insights
- Decorators are functions that wrap other functions to modify their behavior without changing their source code, using Python’s
@syntax as syntactic sugar for function composition - Understanding that functions are first-class objects in Python—assignable to variables, passable as arguments, and returnable from other functions—is essential to mastering decorators
- Always use
functools.wrapsin your decorators to preserve the original function’s metadata, preventing debugging headaches and maintaining proper introspection
What Are Decorators?
Decorators are a powerful Python feature that allows you to modify or enhance functions and methods without directly changing their code. At their core, decorators are simply functions that take another function as an argument, wrap it with additional functionality, and return the modified version.
The @decorator_name syntax you see above function definitions is syntactic sugar. When you write @timer above a function, Python automatically passes that function to the timer decorator and replaces it with the returned wrapper.
Here’s a practical example that measures function execution time:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(2)
return "Done"
slow_function() # Output: slow_function took 2.0001 seconds
Understanding Functions as First-Class Objects
Python treats functions as first-class citizens, meaning they’re objects just like strings, integers, or lists. You can assign them to variables, store them in data structures, pass them as arguments, and return them from other functions. This behavior is fundamental to how decorators work.
def greet(name):
return f"Hello, {name}!"
# Assign function to a variable
say_hello = greet
print(say_hello("Alice")) # Output: Hello, Alice!
# Pass function as an argument
def execute_twice(func, arg):
func(arg)
func(arg)
execute_twice(print, "Python") # Prints "Python" twice
# Return function from function
def create_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
times_three = create_multiplier(3)
print(times_three(10)) # Output: 30
This last example—returning a function from another function—is the foundation of how decorators work. The inner function (multiplier) has access to the outer function’s variables (factor) through closure, which decorators leverage extensively.
Building Your First Decorator
Let’s build a decorator step-by-step. First, without the @ syntax:
def logger(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
def add(a, b):
return a + b
# Manual decoration
add = logger(add)
add(3, 5)
# Output:
# Calling add with args=(3, 5), kwargs={}
# add returned 8
Now with the @ syntax, which does exactly the same thing:
@logger
def multiply(a, b):
return a * b
multiply(4, 7)
# Output:
# Calling multiply with args=(4, 7), kwargs={}
# multiply returned 28
The *args and **kwargs in the wrapper function ensure your decorator works with any function signature, accepting any number of positional and keyword arguments.
Decorators with Arguments
Sometimes you need decorators that accept configuration parameters. This requires an additional layer of nesting—a function that returns a decorator:
def retry(max_attempts=3, delay=1):
def decorator(func):
def wrapper(*args, **kwargs):
attempts = 0
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as e:
attempts += 1
if attempts >= max_attempts:
raise
print(f"Attempt {attempts} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=2)
def unstable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("API unavailable")
return "Success"
Here’s another practical example—a caching decorator with configurable size:
def cache(max_size=128):
def decorator(func):
cached_results = {}
def wrapper(*args):
if args in cached_results:
print(f"Cache hit for {args}")
return cached_results[args]
result = func(*args)
if len(cached_results) >= max_size:
# Simple eviction: remove first item
cached_results.pop(next(iter(cached_results)))
cached_results[args] = result
return result
return wrapper
return decorator
@cache(max_size=3)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
Preserving Function Metadata with functools.wraps
When you wrap a function, you lose its metadata—name, docstring, and other attributes. This breaks introspection and debugging:
def broken_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@broken_decorator
def important_function():
"""This function does important things."""
pass
print(important_function.__name__) # Output: wrapper
print(important_function.__doc__) # Output: None
The functools.wraps decorator solves this by copying metadata from the original function:
from functools import wraps
def proper_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@proper_decorator
def important_function():
"""This function does important things."""
pass
print(important_function.__name__) # Output: important_function
print(important_function.__doc__) # Output: This function does important things.
Always use @wraps(func) in your decorators. It’s a simple addition that prevents countless debugging problems.
Advanced Patterns and Real-World Use Cases
Authentication Decorator
from functools import wraps
def require_auth(func):
@wraps(func)
def wrapper(*args, **kwargs):
# In real code, check session/token
user = kwargs.get('user')
if not user or not user.get('authenticated'):
raise PermissionError("Authentication required")
return func(*args, **kwargs)
return wrapper
@require_auth
def delete_user(user_id, user=None):
return f"Deleted user {user_id}"
Rate Limiting Decorator
import time
from functools import wraps
def rate_limit(calls_per_second=1):
min_interval = 1.0 / calls_per_second
last_called = [0.0]
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def api_call():
print(f"API called at {time.time()}")
Class-Based Decorator
Class-based decorators use the __call__ method, offering better state management for complex scenarios:
class CountCalls:
def __init__(self, func):
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 process_data():
return "Processing..."
process_data() # Call 1 to process_data
process_data() # Call 2 to process_data
Common Pitfalls and Best Practices
Decorator Stacking Order
When stacking decorators, they execute from bottom to top (closest to the function first):
def decorator_a(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("A: Before")
result = func(*args, **kwargs)
print("A: After")
return result
return wrapper
def decorator_b(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("B: Before")
result = func(*args, **kwargs)
print("B: After")
return result
return wrapper
@decorator_a
@decorator_b
def my_function():
print("Function executing")
my_function()
# Output:
# A: Before
# B: Before
# Function executing
# B: After
# A: After
Performance Considerations
Decorators add overhead. For performance-critical code, measure the impact:
# Bad: Creating new objects on every call
def inefficient_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
logger = Logger() # Created every time!
return func(*args, **kwargs)
return wrapper
# Better: Create once
def efficient_decorator(func):
logger = Logger() # Created once at decoration time
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Debugging Decorated Functions
Use the __wrapped__ attribute (added by functools.wraps) to access the original function:
@timer
@logger
def complex_function(x):
return x * 2
# Access original function
original = complex_function.__wrapped__.__wrapped__
Decorators are a cornerstone of idiomatic Python. Master them, and you’ll write cleaner, more maintainable code with powerful abstractions that keep your codebase DRY and focused.