Python - Decorators Tutorial with Examples
Decorators wrap a function or class to extend or modify its behavior. They're callable objects that take a callable as input and return a callable as output. This pattern enables cross-cutting...
Key Insights
- Decorators are functions that modify the behavior of other functions or classes without changing their source code, following the open/closed principle and enabling clean separation of concerns
- Python’s
@decoratorsyntax is syntactic sugar forfunction = decorator(function), making it possible to stack multiple decorators and create powerful composition patterns - Understanding closures,
*args,**kwargs, andfunctools.wrapsis essential for building robust decorators that preserve function metadata and handle arbitrary arguments
What Are Decorators
Decorators wrap a function or class to extend or modify its behavior. They’re callable objects that take a callable as input and return a callable as output. This pattern enables cross-cutting concerns like logging, authentication, caching, and timing without polluting business logic.
def simple_decorator(func):
def wrapper():
print("Before function call")
func()
print("After function call")
return wrapper
@simple_decorator
def say_hello():
print("Hello!")
say_hello()
# Output:
# Before function call
# Hello!
# After function call
The @simple_decorator syntax is equivalent to say_hello = simple_decorator(say_hello). Python replaces the original function with the wrapper function returned by the decorator.
Decorators with Arguments
Real-world functions accept arguments. Use *args and **kwargs to create decorators that work with any function signature:
def logging_decorator(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
@logging_decorator
def add(a, b):
return a + b
@logging_decorator
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
result = add(5, 3)
# Calling add with args=(5, 3), kwargs={}
# add returned 8
message = greet("Alice", greeting="Hi")
# Calling greet with args=('Alice',), kwargs={'greeting': 'Hi'}
# greet returned Hi, Alice!
Preserving Function Metadata
Decorators replace the original function, losing its metadata like __name__, __doc__, and __annotations__. Use functools.wraps to copy metadata from the original function:
from functools import wraps
def my_decorator(func):
@wraps(func) # Preserves func's metadata
def wrapper(*args, **kwargs):
"""Wrapper documentation"""
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate(x, y):
"""Adds two numbers together."""
return x + y
print(calculate.__name__) # calculate (not wrapper)
print(calculate.__doc__) # Adds two numbers together.
Without @wraps(func), calculate.__name__ would be "wrapper" and the docstring would be lost.
Parameterized Decorators
Decorators that accept arguments require an additional layer of nesting. The outer function takes decorator arguments, returns a decorator, which returns a wrapper:
from functools import wraps
import time
def retry(max_attempts=3, delay=1):
def decorator(func):
@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:
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"
The structure is: retry(3, 2) returns a decorator → decorator wraps unstable_api_call → wrapper executes with retry logic.
Class-Based Decorators
Classes implementing __call__ can act as decorators, useful when maintaining state between calls:
from functools import wraps
class CountCalls:
def __init__(self, func):
wraps(func)(self)
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(data):
return data.upper()
process_data("hello") # Call 1 to process_data
process_data("world") # Call 2 to process_data
print(process_data.count) # 2
Practical Example: Caching Decorator
Implement memoization to cache expensive function results:
from functools import wraps
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # Executes quickly due to caching
For production use, consider functools.lru_cache which handles kwargs and provides cache management:
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_computation(x, y):
return x ** y
print(expensive_computation.cache_info()) # CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)
Stacking Decorators
Multiple decorators apply from bottom to top (closest to function first):
from functools import wraps
import time
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.4f}s")
return result
return wrapper
def validate_positive(func):
@wraps(func)
def wrapper(n):
if n < 0:
raise ValueError("Number must be positive")
return func(n)
return wrapper
@timer
@validate_positive
def calculate_factorial(n):
if n == 0:
return 1
return n * calculate_factorial(n - 1)
calculate_factorial(5) # Validates, then times
# calculate_factorial took 0.0001s
Execution order: timer(validate_positive(calculate_factorial))
Class Method Decorators
Decorating class methods requires handling the self parameter:
from functools import wraps
def log_method(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
print(f"Calling {self.__class__.__name__}.{func.__name__}")
return func(self, *args, **kwargs)
return wrapper
class DataProcessor:
def __init__(self, name):
self.name = name
@log_method
def process(self, data):
return f"{self.name}: {data}"
processor = DataProcessor("CSV Processor")
processor.process("data.csv")
# Calling DataProcessor.process
For property decorators, combine with @property:
def validate_range(min_val, max_val):
def decorator(func):
@wraps(func)
def wrapper(self, value):
if not min_val <= value <= max_val:
raise ValueError(f"Value must be between {min_val} and {max_val}")
return func(self, value)
return wrapper
return decorator
class Temperature:
def __init__(self):
self._celsius = 0
@property
def celsius(self):
return self._celsius
@celsius.setter
@validate_range(-273.15, 1000)
def celsius(self, value):
self._celsius = value
Authentication Decorator Pattern
Common pattern for web frameworks and APIs:
from functools import wraps
def require_auth(role=None):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Simulated auth check
current_user = kwargs.get('user')
if not current_user:
raise PermissionError("Authentication required")
if role and current_user.get('role') != role:
raise PermissionError(f"Role '{role}' required")
return func(*args, **kwargs)
return wrapper
return decorator
@require_auth(role='admin')
def delete_user(user_id, user=None):
return f"Deleted user {user_id}"
# delete_user(123) # Raises PermissionError
delete_user(123, user={'role': 'admin'}) # Works
Decorators enable clean, reusable patterns for cross-cutting concerns. Master the fundamentals—closures, argument handling, and metadata preservation—to build maintainable Python applications. Use built-in decorators like @property, @staticmethod, @classmethod, and functools utilities before writing custom solutions.