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 @decorator syntax is syntactic sugar for function = decorator(function), making it possible to stack multiple decorators and create powerful composition patterns
  • Understanding closures, *args, **kwargs, and functools.wraps is 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.