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.wraps to 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:

  1. Create a decorator that measures and prints the execution time of a function.
  2. Write a decorator that validates that all arguments to a function are of a specific type (e.g., strings).
  3. Implement a decorator that limits the rate at which a function can be called (e.g., only once per second).
  4. Create a caching decorator that saves function results to a file and loads them on subsequent calls.
  5. Build a decorator that retries a function with exponential backoff if it raises specific exceptions.
Back to Cheat Sheet