Introduction
Imagine you have a function that calculates the sum of two numbers. Now imagine you need to:
- Log when the function is called
- Measure how long it takes
- Validate the inputs
- Cache the results
Without decorators, you’d modify the function repeatedly, making it bloated and hard to maintain. With decorators, you can add these features cleanly and reusably.
Decorators are one of Python’s most powerful features. They allow you to “wrap” functions or classes to modify their behavior without changing their source code. This enables clean, reusable code and elegant solutions to common problems.
In this guide, we’ll explore how decorators work, understand the mechanics behind them, and master practical patterns you can use immediately.
Understanding Decorators: The Fundamentals
What Is a Decorator?
A decorator is a function that takes another function as input, adds some functionality, and returns a modified version of that function. It’s a way to “decorate” or enhance a function without changing its original code.
Why Decorators Matter
Decorators solve a fundamental problem: separation of concerns. They let you:
- Add functionality without modifying original code
- Reuse code across multiple functions
- Keep code clean and maintainable
- Apply cross-cutting concerns like logging, timing, and validation
Functions as First-Class Objects
To understand decorators, you need to know that in Python, functions are first-class objects. This means:
# Functions can be assigned to variables
def greet():
return "Hello!"
greeting = greet
print(greeting()) # Output: Hello!
# Functions can be passed as arguments
def call_function(func):
return func()
result = call_function(greet)
print(result) # Output: Hello!
# Functions can be returned from functions
def create_function():
def inner():
return "I'm from inner!"
return inner
new_func = create_function()
print(new_func()) # Output: I'm from inner!
Closures: The Key to Decorators
Decorators rely on closuresโfunctions that remember variables from their enclosing scope:
def outer(x):
# x is in the enclosing scope
def inner(y):
# inner "remembers" x even after outer returns
return x + y
return inner
add_five = outer(5)
print(add_five(3)) # Output: 8
print(add_five(10)) # Output: 15
Basic Decorator Syntax
Creating Your First Decorator
# Step 1: Define a decorator function
def my_decorator(func):
"""A simple decorator that prints before and after function execution."""
def wrapper():
print("Something before the function")
func()
print("Something after the function")
return wrapper
# Step 2: Apply the decorator
@my_decorator
def say_hello():
print("Hello!")
# Step 3: Call the decorated function
say_hello()
# Output:
# Something before the function
# Hello!
# Something after the function
Understanding the @ Syntax
The @ symbol is syntactic sugar. These are equivalent:
# Using @ syntax
@my_decorator
def say_hello():
print("Hello!")
# Without @ syntax (what actually happens)
def say_hello():
print("Hello!")
say_hello = my_decorator(say_hello)
Decorators with Arguments
To pass arguments to the decorated function, use *args and **kwargs:
def my_decorator(func):
"""Decorator that works with functions that have arguments."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"Result: {result}")
return result
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers."""
return a + b
add(5, 3)
# Output:
# Calling add with args=(5, 3), kwargs={}
# Result: 8
Preserving Function Metadata
When you decorate a function, it loses its original metadata. Use functools.wraps to preserve it:
import functools
def my_decorator(func):
@functools.wraps(func) # Preserves func's metadata
def wrapper(*args, **kwargs):
"""Wrapper function."""
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # Output: add (not wrapper)
print(add.__doc__) # Output: Add two numbers.
Common Decorator Patterns
Pattern 1: Timing Decorator
Measure how long a function takes to execute:
import functools
import time
def timer(func):
"""Decorator that measures function execution time."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
elapsed = end_time - start_time
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
"""Simulate a slow operation."""
time.sleep(2)
return "Done!"
slow_function()
# Output:
# slow_function took 2.0001 seconds
# Done!
Pattern 2: Logging Decorator
Log function calls and results:
import functools
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_calls(func):
"""Decorator that logs function calls."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
try:
result = func(*args, **kwargs)
logger.info(f"{func.__name__} returned {result}")
return result
except Exception as e:
logger.error(f"{func.__name__} raised {type(e).__name__}: {e}")
raise
return wrapper
@log_calls
def divide(a, b):
"""Divide two numbers."""
return a / b
divide(10, 2) # Logs successful call
divide(10, 0) # Logs error
# Output:
# INFO:__main__:Calling divide with args=(10, 2), kwargs={}
# INFO:__main__:divide returned 5.0
# INFO:__main__:Calling divide with args=(10, 0), kwargs={}
# ERROR:__main__:divide raised ZeroDivisionError: division by zero
Pattern 3: Input Validation Decorator
Validate function arguments:
import functools
def validate_positive(*arg_names):
"""Decorator that validates arguments are positive."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Get function parameter names
import inspect
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
# Validate specified arguments
for arg_name in arg_names:
if arg_name in bound_args.arguments:
value = bound_args.arguments[arg_name]
if value <= 0:
raise ValueError(f"{arg_name} must be positive, got {value}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate_positive("a", "b")
def divide(a, b):
"""Divide two positive numbers."""
return a / b
print(divide(10, 2)) # Output: 5.0
print(divide(-10, 2)) # Raises ValueError: a must be positive, got -10
Pattern 4: Memoization/Caching Decorator
Cache function results to avoid redundant calculations:
import functools
def memoize(func):
"""Decorator that caches function results."""
cache = {}
@functools.wraps(func)
def wrapper(*args):
# Check if result is in cache
if args in cache:
print(f"Cache hit for {func.__name__}{args}")
return cache[args]
# Calculate and cache result
print(f"Computing {func.__name__}{args}")
result = func(*args)
cache[args] = result
return result
return wrapper
@memoize
def fibonacci(n):
"""Calculate fibonacci number (with caching)."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(5))
# Output:
# Computing fibonacci(5)
# Computing fibonacci(4)
# Computing fibonacci(3)
# Computing fibonacci(2)
# Computing fibonacci(1)
# Computing fibonacci(0)
# Cache hit for fibonacci(1)
# Cache hit for fibonacci(2)
# ... (many cache hits)
# 5
Pattern 5: Authentication/Authorization Decorator
Control access to functions:
import functools
# Simulate a current user
current_user = {"name": "Alice", "role": "admin"}
def require_role(required_role):
"""Decorator that checks if user has required role."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if current_user.get("role") != required_role:
raise PermissionError(
f"User {current_user['name']} does not have {required_role} role"
)
return func(*args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user_id):
"""Delete a user (admin only)."""
print(f"Deleting user {user_id}")
return True
@require_role("user")
def view_profile():
"""View user profile."""
print(f"Viewing profile for {current_user['name']}")
return True
delete_user(123) # Works (user is admin)
view_profile() # Works (user is admin, which includes user role)
# Change user role
current_user["role"] = "user"
delete_user(123) # Raises PermissionError
Pattern 6: Retry Decorator
Retry a function if it fails:
import functools
import time
def retry(max_attempts=3, delay=1):
"""Decorator that retries a function on failure."""
def decorator(func):
@functools.wraps(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:
print(f"Failed after {max_attempts} attempts")
raise
print(f"Attempt {attempts} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
# Simulate an unreliable function
attempt_count = 0
@retry(max_attempts=3, delay=1)
def unreliable_function():
"""Function that fails the first two times."""
global attempt_count
attempt_count += 1
if attempt_count < 3:
raise ConnectionError("Connection failed")
return "Success!"
print(unreliable_function())
# Output:
# Attempt 1 failed: Connection failed. Retrying in 1s...
# Attempt 2 failed: Connection failed. Retrying in 1s...
# Success!
Pattern 7: Rate Limiting Decorator
Limit how often a function can be called:
import functools
import time
def rate_limit(calls_per_second=1):
"""Decorator that limits function call frequency."""
min_interval = 1.0 / calls_per_second
last_called = [0.0] # Use list to allow modification in nested function
def decorator(func):
@functools.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) # Allow 2 calls per second
def api_call():
"""Simulate an API call."""
print(f"API called at {time.time():.2f}")
# Call 5 times - should be rate limited
for i in range(5):
api_call()
# Output shows calls are spaced 0.5 seconds apart
Pattern 8: Decorator with Optional Arguments
Create a decorator that works with or without arguments:
import functools
def optional_decorator(func=None, *, prefix=""):
"""Decorator that works with or without arguments."""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
if prefix:
print(f"{prefix}: Calling {f.__name__}")
return f(*args, **kwargs)
return wrapper
# If called without arguments: @optional_decorator
if func is not None:
return decorator(func)
# If called with arguments: @optional_decorator(prefix="LOG")
return decorator
# Without arguments
@optional_decorator
def greet1():
print("Hello!")
# With arguments
@optional_decorator(prefix="LOG")
def greet2():
print("Hello!")
greet1() # Output: Hello!
greet2() # Output: LOG: Calling greet2 / Hello!
Stacking Decorators
You can apply multiple decorators to a single function:
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"Took {time.time() - start:.4f}s")
return result
return wrapper
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
# Stack decorators - applied bottom to top
@timer
@log_calls
def slow_function():
time.sleep(1)
return "Done"
slow_function()
# Output:
# Calling slow_function
# Took 1.0001s
Class Decorators
Decorators can also be applied to classes:
import functools
def add_repr(cls):
"""Decorator that adds a __repr__ method to a class."""
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("Alice", 30)
print(person) # Output: Person(name='Alice', age=30)
Best Practices
Practice 1: Always Use functools.wraps
import functools
# Good: Preserves function metadata
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Bad: Loses function metadata
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Practice 2: Keep Decorators Simple
# Bad: Decorator does too much
def complex_decorator(func):
def wrapper(*args, **kwargs):
# Logging
# Timing
# Validation
# Caching
# Error handling
# ... too much!
return func(*args, **kwargs)
return wrapper
# Good: Single responsibility
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"Took {time.time() - start:.4f}s")
return result
return wrapper
Practice 3: Document Decorators
def my_decorator(func):
"""Add functionality to a function.
This decorator does X, Y, and Z.
Args:
func: The function to decorate
Returns:
The decorated function
Example:
@my_decorator
def my_function():
pass
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Practice 4: Test Decorated Functions
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Add functionality
return func(*args, **kwargs)
return wrapper
# Test the decorator
@my_decorator
def add(a, b):
return a + b
# Test cases
assert add(2, 3) == 5
assert add(-1, 1) == 0
print("All tests passed!")
Common Pitfalls
Pitfall 1: Forgetting functools.wraps
# Bad: Function metadata is lost
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def my_function():
"""My function."""
pass
print(my_function.__name__) # Output: wrapper (wrong!)
print(my_function.__doc__) # Output: None (wrong!)
# Good: Use functools.wraps
import functools
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good_decorator
def my_function():
"""My function."""
pass
print(my_function.__name__) # Output: my_function (correct!)
print(my_function.__doc__) # Output: My function. (correct!)
Pitfall 2: Incorrect Decorator Order
# Order matters when stacking decorators
@decorator1
@decorator2
def func():
pass
# Is equivalent to:
func = decorator1(decorator2(func))
# So decorator2 is applied first, then decorator1
Pitfall 3: Mutable Default Arguments
# Bad: Mutable default argument shared across calls
def bad_cache(func):
cache = {} # Shared across all decorated functions!
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
# Good: Each decorated function gets its own cache
def good_cache(func):
@functools.wraps(func)
def wrapper(*args):
if not hasattr(wrapper, 'cache'):
wrapper.cache = {}
if args not in wrapper.cache:
wrapper.cache[args] = func(*args)
return wrapper.cache[args]
return wrapper
Real-World Example: Complete Decorator Suite
import functools
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def timer(func):
"""Measure function execution time."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
logger.info(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
def log_calls(func):
"""Log function calls."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
try:
result = func(*args, **kwargs)
logger.info(f"{func.__name__} returned {result}")
return result
except Exception as e:
logger.error(f"{func.__name__} raised {type(e).__name__}: {e}")
raise
return wrapper
def validate_positive(*arg_names):
"""Validate arguments are positive."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
import inspect
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
for arg_name in arg_names:
if arg_name in bound_args.arguments:
value = bound_args.arguments[arg_name]
if value <= 0:
raise ValueError(f"{arg_name} must be positive")
return func(*args, **kwargs)
return wrapper
return decorator
# Use all decorators together
@timer
@log_calls
@validate_positive("a", "b")
def divide(a, b):
"""Divide two positive numbers."""
return a / b
# Test
print(divide(10, 2))
# Output:
# INFO:__main__:Calling divide with args=(10, 2), kwargs={}
# INFO:__main__:divide returned 5.0
# INFO:__main__:divide took 0.0001s
# 5.0
Conclusion
Decorators are a powerful Python feature that enable clean, reusable code. They solve the problem of adding functionality to functions without modifying their source code.
Key takeaways:
- Decorators are functions that take functions as input and return modified versions
- Functions are first-class objects in Python, enabling decorator patterns
- Closures allow decorators to remember variables from their enclosing scope
- The @ syntax is syntactic sugar for function wrapping
functools.wrapspreserves function metadata- Common patterns include timing, logging, validation, caching, and authentication
- Decorators can be stacked to apply multiple transformations
- Keep decorators simple with single responsibility
- Always document your decorators
- Test decorated functions thoroughly
Master decorators, and you’ll write more elegant, maintainable Python code. They’re a hallmark of Pythonic programming.
Happy decorating! ๐โจ
Quick Reference
import functools
# Basic decorator
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Do something before
result = func(*args, **kwargs)
# Do something after
return result
return wrapper
# Apply decorator
@my_decorator
def my_function():
pass
# Decorator with arguments
def decorator_with_args(arg1, arg2):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Use arg1 and arg2
return func(*args, **kwargs)
return wrapper
return decorator
@decorator_with_args("value1", "value2")
def my_function():
pass
# Stack decorators
@decorator1
@decorator2
@decorator3
def my_function():
pass
# Class decorator
def class_decorator(cls):
# Modify class
return cls
@class_decorator
class MyClass:
pass
Comments