Decorators in Python
Decorators are a powerful feature in Python that allows you to modify or enhance functions and methods without changing their core implementation. They work by wrapping a function within another function, enabling you to execute code before and after the wrapped function runs.
Decorator Fundamentals
Basic Decorator Structure
At its core, a decorator is a function that takes another function as an argument and returns a new function that usually extends the behavior of the original function.
# Basic decorator pattern def my_decorator(func): def wrapper(): print("Something happens before the function is called.") func() print("Something happens after the function is called.") return wrapper # Applying the decorator manually def say_hello(): print("Hello!") # This is what happens behind the scenes with the @ syntax say_hello = my_decorator(say_hello) say_hello() # Output: # Something happens before the function is called. # Hello! # Something happens after the function is called. # The same using decorator syntax @my_decorator def say_goodbye(): print("Goodbye!") say_goodbye() # Output is the same pattern as above
Decorators with Arguments
You can create decorators that handle functions with arguments by using *args and **kwargs.
# Decorator for functions with arguments def my_decorator(func): def wrapper(*args, **kwargs): print(f"Function {func.__name__} is about to be called with {args} and {kwargs}") result = func(*args, **kwargs) print(f"Function {func.__name__} returned {result}") return result return wrapper @my_decorator def add(a, b): return a + b result = add(3, 5) # Output: # Function add is about to be called with (3, 5) and {} # Function add returned 8
Preserving Metadata
When you decorate a function, the metadata of the original function (like name, docstring) is lost.
Use the @functools.wraps decorator to preserve this information.
# Without wraps, metadata is lost def my_decorator(func): def wrapper(*args, **kwargs): """Wrapper function""" return func(*args, **kwargs) return wrapper @my_decorator def greet(name): """Greet a person by name""" return f"Hello, {name}!" print(greet.__name__) # Output: 'wrapper' print(greet.__doc__) # Output: 'Wrapper function' # Using functools.wraps to preserve metadata import functools def better_decorator(func): @functools.wraps(func) # Preserves metadata def wrapper(*args, **kwargs): """Wrapper function""" return func(*args, **kwargs) return wrapper @better_decorator def greet_better(name): """Greet a person by name""" return f"Hello, {name}!" print(greet_better.__name__) # Output: 'greet_better' print(greet_better.__doc__) # Output: 'Greet a person by name'
Common Use Cases
Timing Functions
A common use for decorators is to measure the execution time of functions.
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} ran in {end_time - start_time:.6f} seconds")
return result
return wrapper
@timer
def slow_function(delay=1.0):
"""Simulates a slow function"""
time.sleep(delay)
return "Function executed"
slow_function(0.5)
# Output: slow_function ran in 0.500123 seconds
Logging and Debugging
Decorators can be used to add logging behavior to functions without cluttering the function's core logic.
import functools import logging # Set up basic logging logging.basicConfig(level=logging.INFO) def log_function_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): args_repr = [repr(a) for a in args] kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()] signature = ", ".join(args_repr + kwargs_repr) logging.info(f"Calling {func.__name__}({signature})") result = func(*args, **kwargs) logging.info(f"{func.__name__} returned {result!r}") return result return wrapper @log_function_call def calculate_area(length, width): area = length * width return area result = calculate_area(10, width=5) # Logs: # INFO:root:Calling calculate_area(10, width=5) # INFO:root:calculate_area returned 50
Authentication and Authorization
Decorators can enforce authentication and authorization checks before executing a function.
def require_auth(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if not user.is_authenticated:
raise PermissionError("Authentication required")
return func(user, *args, **kwargs)
return wrapper
# Simulated User class
class User:
def __init__(self, name, is_authenticated=False, is_admin=False):
self.name = name
self.is_authenticated = is_authenticated
self.is_admin = is_admin
# Decorator for admin permissions
def admin_only(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if not user.is_admin:
raise PermissionError("Admin privileges required")
return func(user, *args, **kwargs)
return wrapper
# Using both decorators
@require_auth
@admin_only
def delete_user(current_user, user_to_delete):
print(f"{current_user.name} deleted user {user_to_delete}")
return True
# Try with different users
admin_user = User("Admin", is_authenticated=True, is_admin=True)
regular_user = User("Regular", is_authenticated=True, is_admin=False)
guest_user = User("Guest", is_authenticated=False)
# This will work
delete_user(admin_user, "John")
# This will raise PermissionError: Admin privileges required
# delete_user(regular_user, "John")
# This will raise PermissionError: Authentication required
# delete_user(guest_user, "John")
Caching and Memoization
Decorators can add caching functionality to avoid redundant computations.
import functools
def memoize(func):
"""Cache the results of a function based on its arguments"""
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Create a key that uniquely identifies the function call
key = str(args) + str(kwargs)
if key not in cache:
cache[key] = func(*args, **kwargs)
print(f"Calculated result for {key}: {cache[key]}")
else:
print(f"Using cached result for {key}: {cache[key]}")
return cache[key]
return wrapper
@memoize
def fibonacci(n):
"""Calculate the Fibonacci number recursively"""
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# First call - computes and caches results
print(fibonacci(5))
# Second call - uses cached results
print(fibonacci(5))
Advanced Decorator Patterns
Decorators with Parameters
You can create decorators that accept their own parameters by adding another layer of nesting.
def repeat(num_times):
"""Decorator that repeats the function call `num_times` times"""
def decorator_repeat(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(num_times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator_repeat
@repeat(3)
def greet(name):
return f"Hello, {name}!"
print(greet("World"))
# Output: ['Hello, World!', 'Hello, World!', 'Hello, World!']
Class-based Decorators
Decorators can also be implemented as classes, using the __call__ method to make the class instance callable.
class CountCalls:
"""Class decorator that counts how many times a function has been called"""
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"{self.func.__name__} has been called {self.count} times")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
return "Hello!"
# Call the function multiple times
say_hello() # Output: say_hello has been called 1 times
say_hello() # Output: say_hello has been called 2 times
say_hello() # Output: say_hello has been called 3 times
Stateful Decorators
Decorators can maintain state between function calls.
def rate_limiter(max_calls, period):
"""Limit function calls to max_calls per period seconds"""
calls = []
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
current_time = time.time()
# Remove old calls outside the period
while calls and current_time - calls[0] > period:
calls.pop(0)
# Check if we've hit the rate limit
if len(calls) >= max_calls:
time_to_wait = period - (current_time - calls[0])
print(f"Rate limit exceeded. Try again in {time_to_wait:.2f} seconds.")
return None
# Add current call timestamp
calls.append(current_time)
return func(*args, **kwargs)
return wrapper
return decorator
# Allow 2 calls per 5 seconds
@rate_limiter(max_calls=2, period=5)
def fetch_data():
return "Data fetched successfully"
# Try calling multiple times in quick succession
print(fetch_data()) # Works
print(fetch_data()) # Works
print(fetch_data()) # Rate limit message
Decorator Stacking
Multiple decorators can be stacked on a single function, executing from the innermost to the outermost.
def uppercase(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def split_words(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.split()
return wrapper
# Decorators are applied from bottom to top
@uppercase
@split_words
def greet(name):
return f"hello {name}"
print(greet("world"))
# First 'hello world' is split into ['hello', 'world']
# Then uppercase is applied: ['HELLO', 'WORLD']
Best Practices and Patterns
Decorator Best Practices
- Always use
functools.wrapsto preserve the original function's metadata - Make decorators robust by handling *args and **kwargs
- Keep decorators focused on a single responsibility
- Document how the decorator modifies the function's behavior
- Consider making decorators optional with sensible defaults
- Be careful with stateful decorators in multi-threaded environments
Common Patterns
| Pattern | Use Case |
|---|---|
| Pre/Post Processing | Run code before/after a function (logging, validation) |
| Function Registration | Register functions in a registry (routes in web frameworks) |
| Result Modification | Transform the result of a function (caching, formatting) |
| Retry Logic | Retry a function call on failure with backoff |
| Context Management | Set up and tear down resources around function calls |
Example: Retry Decorator
Here's a practical example of a retry decorator with exponential backoff:
import time
import random
import functools
def retry(max_tries=3, delay_seconds=1, backoff=2, exceptions=(Exception,)):
"""Retry decorator with exponential backoff"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
mtries, mdelay = max_tries, delay_seconds
while mtries > 0:
try:
return func(*args, **kwargs)
except exceptions as e:
mtries -= 1
if mtries == 0:
raise
print(f"Function {func.__name__} failed with {e}. Retrying in {mdelay} seconds...")
time.sleep(mdelay)
mdelay *= backoff
return func(*args, **kwargs)
return wrapper
return decorator
# Example usage with a flaky function
@retry(max_tries=3, delay_seconds=1, backoff=2, exceptions=(ValueError,))
def flaky_function():
"""This function randomly fails"""
if random.random() < 0.7: # 70% chance of failure
raise ValueError("Random failure!")
return "Success!"
# Try calling the flaky function
try:
result = flaky_function()
print(result)
except ValueError as e:
print(f"Function failed after retries: {e}")
Real-World Examples
Python Standard Library Decorators
The Python standard library includes useful decorators that you can use in your code:
@classmethod- Defines a method that operates on the class rather than instances@staticmethod- Defines a method that doesn't require access to the class or instance@property- Creates a getter method for an attribute, enabling property-like access@functools.lru_cache- Adds least-recently-used caching to functions@functools.singledispatch- Enables function overloading based on argument types@contextlib.contextmanager- Creates a context manager from a generator function
# Example of @property decorator class Circle: def __init__(self, radius): self._radius = radius @property def radius(self): """Get the radius of the circle""" return self._radius @radius.setter def radius(self, value): """Set the radius of the circle""" if value < 0: raise ValueError("Radius cannot be negative") self._radius = value @property def area(self): """Calculate the area of the circle""" import math return math.pi * self._radius ** 2 # Usage circle = Circle(5) print(circle.radius) # 5 print(circle.area) # 78.54... circle.radius = 10 print(circle.radius) # 10 print(circle.area) # 314.16... # This will raise a ValueError # circle.radius = -1
Decorators in Web Frameworks
Web frameworks like Flask and Django make heavy use of decorators for route definitions, authentication, and more:
# Example of Flask route decorators from flask import Flask, request, jsonify app = Flask(__name__) # Route decorator defines URL patterns @app.route('/api/items', methods=['GET']) def get_items(): return jsonify({"items": ["item1", "item2"]}) # Authentication decorator def login_required(func): @functools.wraps(func) def wrapper(*args, **kwargs): auth_header = request.headers.get('Authorization') if not auth_header: return jsonify({"error": "Authentication required"}), 401 # Here you would validate the token # For this example, we just check if the token is "valid_token" token = auth_header.split(" ")[1] if len(auth_header.split(" ")) > 1 else "" if token != "valid_token": return jsonify({"error": "Invalid token"}), 401 return func(*args, **kwargs) return wrapper # Using both decorators @app.route('/api/protected', methods=['GET']) @login_required def protected_endpoint(): return jsonify({"message": "You've accessed the protected endpoint"})
Practice Exercises
Try These:
- Create a decorator that measures and prints the execution time of a function.
- Write a decorator that validates that all arguments to a function are of a specific type (e.g., strings).
- Implement a decorator that limits the rate at which a function can be called (e.g., only once per second).
- Create a caching decorator that saves function results to a file and loads them on subsequent calls.
- Build a decorator that retries a function with exponential backoff if it raises specific exceptions.