Decorators are functions that wrap other functions, adding functionality without permanently modifying the original function’s code.

Let’s see a simple decorator in action. Imagine we want to log when a function is called and what arguments it receives.

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling function '{func.__name__}' with args: {args}, kwargs: {kwargs}")
        result = func(*args, **kwargs)
        print(f"Function '{func.__name__}' returned: {result}")
        return result
    return wrapper

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

add(5, 3)
# Output:
# Calling function 'add' with args: (5, 3), kwargs: {}
# Function 'add' returned: 8

Here, log_calls is a decorator. It takes a function (func) and returns a new function (wrapper). The wrapper function executes our logging logic before and after calling the original func. @functools.wraps(func) is crucial; it preserves the original function’s metadata (like its name and docstring), which is essential for introspection and debugging.

Beyond simple logging, decorators can enforce constraints, manage resources, or even alter function behavior based on external factors. They’re a powerful tool for keeping code DRY (Don’t Repeat Yourself) and for creating reusable, modular functionality.

Consider a decorator that caches function results. This is incredibly useful for computationally expensive functions where the same inputs are likely to be encountered repeatedly.

import functools
import time

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Create a cache key. For simplicity, we'll assume hashable args.
        # More complex keys might involve sorting kwargs or using a custom object.
        key = (args, tuple(sorted(kwargs.items())))
        if key not in cache:
            print(f"Cache miss for {func.__name__} with key: {key}")
            cache[key] = func(*args, **kwargs)
        else:
            print(f"Cache hit for {func.__name__} with key: {key}")
        return cache[key]
    return wrapper

@memoize
def slow_computation(n):
    print(f"Performing slow computation for {n}...")
    time.sleep(2) # Simulate a long-running task
    return n * n

print(slow_computation(5))
print(slow_computation(5))
print(slow_computation(10))
# Output:
# Performing slow computation for 5...
# Cache miss for slow_computation with key: ((5,), ())
# 25
# Cache hit for slow_computation with key: ((5,), ())
# 25
# Performing slow computation for 10...
# Cache miss for slow_computation with key: ((10,), ())
# 100

This memoize decorator stores the results of slow_computation in a dictionary. The first time slow_computation(5) is called, it’s computed and stored. Subsequent calls with 5 hit the cache, avoiding the expensive computation and the time.sleep(2). The key generation is a critical part of memoization; it must uniquely represent the function’s inputs.

Decorators can also be parameterized. This means you can pass arguments to the decorator itself, allowing for more flexible customization. To do this, you create a decorator factory – a function that returns a decorator.

import functools

def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

Here, repeat is the factory function. It takes num_times and returns decorator_repeat. decorator_repeat then takes the function to be decorated (func) and returns the wrapper. This pattern is common for creating decorators that need configuration.

A more advanced pattern involves decorators that modify the function signature or handle complex argument types. For instance, a decorator that ensures a specific argument is always a certain type, or one that injects dependencies.

Consider a decorator that adds a default value to an argument if it’s not provided, but only if the argument is explicitly passed as None. This is a subtle distinction from simply setting a default in the function definition itself.

import functools
from inspect import signature

def default_if_none(arg_name, default_value):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            sig = signature(func)
            bound_args = sig.bind(*args, **kwargs)
            
            if arg_name in bound_args.arguments:
                if bound_args.arguments[arg_name] is None:
                    bound_args.arguments[arg_name] = default_value
            else:
                # If the argument wasn't passed at all, and it's not optional,
                # this would normally raise an error. We'll assume it's handled
                # by the base function's signature or default.
                pass 

            # Reconstruct args and kwargs from the potentially modified bound_args
            new_args, new_kwargs = bound_args.args, bound_args.kwargs
            return func(*new_args, **new_kwargs)
        return wrapper
    return decorator

@default_if_none('user_id', 1001)
def get_user_profile(user_id=None, name="Guest"):
    if user_id is None: # This check is now redundant due to the decorator
        user_id = 9999
    print(f"Fetching profile for User ID: {user_id}, Name: {name}")

get_user_profile(name="Bob")
get_user_profile(user_id=None, name="Charlie")
get_user_profile(user_id=2005, name="David")
# Output:
# Fetching profile for User ID: 1001, Name: Bob
# Fetching profile for User ID: 1001, Name: Charlie
# Fetching profile for User ID: 2005, Name: David

In default_if_none, we inspect the function’s signature using inspect.signature. sig.bind(*args, **kwargs) cleverly maps the positional and keyword arguments passed to the wrapper to the function’s defined parameters, creating a BoundArguments object. We can then modify the arguments dictionary within bound_args. If the target arg_name was explicitly passed as None, we replace it with default_value. Finally, we reconstruct the arguments and call the original function. This decorator ensures that if user_id is None, it’s automatically set to 1001 before get_user_profile is even executed.

The most surprising aspect of decorators is how they allow you to implement aspects of functional programming, like aspect-oriented programming (AOP) patterns, directly within Python’s object-oriented syntax. You can weave cross-cutting concerns like authentication, logging, or transaction management into multiple functions without modifying their core logic, making your codebase cleaner and more maintainable.

The next logical step after mastering decorators is understanding how they interact with class methods and how to apply them to class definitions themselves.

Want structured learning?

Take the full Python course →