Python Decorators: Complete Guide with Examples

Decorators are a powerful Python feature that allows you to modify or enhance functions and methods without directly changing their code. At their core, decorators are simply functions that take...

Key Insights

  • Decorators are functions that wrap other functions to modify their behavior without changing their source code, using Python’s @ syntax as syntactic sugar for function composition
  • Understanding that functions are first-class objects in Python—assignable to variables, passable as arguments, and returnable from other functions—is essential to mastering decorators
  • Always use functools.wraps in your decorators to preserve the original function’s metadata, preventing debugging headaches and maintaining proper introspection

What Are Decorators?

Decorators are a powerful Python feature that allows you to modify or enhance functions and methods without directly changing their code. At their core, decorators are simply functions that take another function as an argument, wrap it with additional functionality, and return the modified version.

The @decorator_name syntax you see above function definitions is syntactic sugar. When you write @timer above a function, Python automatically passes that function to the timer decorator and replaces it with the returned wrapper.

Here’s a practical example that measures function execution time:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(2)
    return "Done"

slow_function()  # Output: slow_function took 2.0001 seconds

Understanding Functions as First-Class Objects

Python treats functions as first-class citizens, meaning they’re objects just like strings, integers, or lists. You can assign them to variables, store them in data structures, pass them as arguments, and return them from other functions. This behavior is fundamental to how decorators work.

def greet(name):
    return f"Hello, {name}!"

# Assign function to a variable
say_hello = greet
print(say_hello("Alice"))  # Output: Hello, Alice!

# Pass function as an argument
def execute_twice(func, arg):
    func(arg)
    func(arg)

execute_twice(print, "Python")  # Prints "Python" twice

# Return function from function
def create_multiplier(factor):
    def multiplier(x):
        return x * factor
    return multiplier

times_three = create_multiplier(3)
print(times_three(10))  # Output: 30

This last example—returning a function from another function—is the foundation of how decorators work. The inner function (multiplier) has access to the outer function’s variables (factor) through closure, which decorators leverage extensively.

Building Your First Decorator

Let’s build a decorator step-by-step. First, without the @ syntax:

def logger(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

def add(a, b):
    return a + b

# Manual decoration
add = logger(add)
add(3, 5)
# Output:
# Calling add with args=(3, 5), kwargs={}
# add returned 8

Now with the @ syntax, which does exactly the same thing:

@logger
def multiply(a, b):
    return a * b

multiply(4, 7)
# Output:
# Calling multiply with args=(4, 7), kwargs={}
# multiply returned 28

The *args and **kwargs in the wrapper function ensure your decorator works with any function signature, accepting any number of positional and keyword arguments.

Decorators with Arguments

Sometimes you need decorators that accept configuration parameters. This requires an additional layer of nesting—a function that returns a decorator:

def retry(max_attempts=3, delay=1):
    def decorator(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"

Here’s another practical example—a caching decorator with configurable size:

def cache(max_size=128):
    def decorator(func):
        cached_results = {}
        
        def wrapper(*args):
            if args in cached_results:
                print(f"Cache hit for {args}")
                return cached_results[args]
            
            result = func(*args)
            if len(cached_results) >= max_size:
                # Simple eviction: remove first item
                cached_results.pop(next(iter(cached_results)))
            cached_results[args] = result
            return result
        return wrapper
    return decorator

@cache(max_size=3)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

Preserving Function Metadata with functools.wraps

When you wrap a function, you lose its metadata—name, docstring, and other attributes. This breaks introspection and debugging:

def broken_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@broken_decorator
def important_function():
    """This function does important things."""
    pass

print(important_function.__name__)  # Output: wrapper
print(important_function.__doc__)   # Output: None

The functools.wraps decorator solves this by copying metadata from the original function:

from functools import wraps

def proper_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@proper_decorator
def important_function():
    """This function does important things."""
    pass

print(important_function.__name__)  # Output: important_function
print(important_function.__doc__)   # Output: This function does important things.

Always use @wraps(func) in your decorators. It’s a simple addition that prevents countless debugging problems.

Advanced Patterns and Real-World Use Cases

Authentication Decorator

from functools import wraps

def require_auth(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # In real code, check session/token
        user = kwargs.get('user')
        if not user or not user.get('authenticated'):
            raise PermissionError("Authentication required")
        return func(*args, **kwargs)
    return wrapper

@require_auth
def delete_user(user_id, user=None):
    return f"Deleted user {user_id}"

Rate Limiting Decorator

import time
from functools import wraps

def rate_limit(calls_per_second=1):
    min_interval = 1.0 / calls_per_second
    last_called = [0.0]
    
    def decorator(func):
        @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)
def api_call():
    print(f"API called at {time.time()}")

Class-Based Decorator

Class-based decorators use the __call__ method, offering better state management for complex scenarios:

class CountCalls:
    def __init__(self, func):
        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():
    return "Processing..."

process_data()  # Call 1 to process_data
process_data()  # Call 2 to process_data

Common Pitfalls and Best Practices

Decorator Stacking Order

When stacking decorators, they execute from bottom to top (closest to the function first):

def decorator_a(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("A: Before")
        result = func(*args, **kwargs)
        print("A: After")
        return result
    return wrapper

def decorator_b(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("B: Before")
        result = func(*args, **kwargs)
        print("B: After")
        return result
    return wrapper

@decorator_a
@decorator_b
def my_function():
    print("Function executing")

my_function()
# Output:
# A: Before
# B: Before
# Function executing
# B: After
# A: After

Performance Considerations

Decorators add overhead. For performance-critical code, measure the impact:

# Bad: Creating new objects on every call
def inefficient_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logger = Logger()  # Created every time!
        return func(*args, **kwargs)
    return wrapper

# Better: Create once
def efficient_decorator(func):
    logger = Logger()  # Created once at decoration time
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Debugging Decorated Functions

Use the __wrapped__ attribute (added by functools.wraps) to access the original function:

@timer
@logger
def complex_function(x):
    return x * 2

# Access original function
original = complex_function.__wrapped__.__wrapped__

Decorators are a cornerstone of idiomatic Python. Master them, and you’ll write cleaner, more maintainable code with powerful abstractions that keep your codebase DRY and focused.

Liked this? There's more.

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