Python decorators can be confusing at first, but they're one of the most powerful features in the language. Before diving into the syntax, let's build intuition with an everyday analogy.
Imagine wearing thermal underwear under your regular underwear. The base layer keeps you warm without altering the underwear's core purpose. Similarly, a decorator wraps a function to add new behavior without modifyign the original implementation. The thermal pants sit outside but enhance what you're already wearing.
Functions as First-Class Citizens
In Python, functions behave like any other object. You can assign them to variables, pass them as arguments, return them from other functions, and define them inside other functions:
def greet():
print("Hello")
def execute(func):
func()
execute(greet)
This flexibility is what makes decorators possible.
Your First Decorator
Suppose you need to log when a function runs. One approach is manually adding logging calls to each function, but this creates repetitive code:
def add(a, b):
print(f"Adding {a} and {b}")
return a + b
A better solution wraps the function in another function that handles logging:
def with_logging(func):
def inner(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
return result
return inner
def add(a, b):
return a + b
add = with_logging(add)
add(2, 3)
The with_logging decorator returns inner, which executes the original function while adding logging behavior. Every call to add now triggers the wrapper first.
The @ Syntax
Python provides syntactic sugar that eliminates the explicit reassignment. Place @decorator_name above your function definition:
def with_logging(func):
def inner(*args, **kwargs):
print(f"Invoking {func.__name__}")
return func(*args, **kwargs)
return inner
@with_logging
def multiply(x, y):
return x * y
multiply(4, 5)
This produces identical behavior to multiply = with_logging(multiply). The @ syntax makes decorators cleaner and more readable.
Handling Function Arguments
What happens when your function accepts parameters? The wrapper must forward them transparently:
def with_logging(func):
def inner(*args, **kwargs):
print(f"Entering {func.__name__}")
return func(*args, **kwargs)
return inner
@with_logging
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}")
greet("Alice", greeting="Hi")
Using *args and **kwargs in the wrapper allows any combination of positional and keyword arguments to pass through to the original function.
Decorators with Parameters
Sometimes you need configuration. Decorators can accept their own parameters by adding an extra wrapping layer:
def log_level(level):
def decorator(func):
def wrapper(*args, **kwargs):
if level == "debug":
print(f"[DEBUG] Running {func.__name__}")
elif level == "error":
print(f"[ERROR] Running {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log_level("debug")
def process(data):
return data
When you write @log_level("debug"), Python first calls log_level("debug"), which returns the actual decorator function. That decorator then receives the function being decorated.
Class-Based Decorators
Decorators aren't limited to functions—they can be classes too. This approach uses the __call__ method, which executes when an instance is invoked:
class TimingDecorator:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
import time
start = time.time()
result = self.func(*args, **kwargs)
end = time.time()
print(f"{self.func.__name__} took {end - start:.4f} seconds")
return result
@TimingDecorator
def slow_operation():
import time
time.sleep(1)
return "Done"
slow_operation()
Preserving Function Metadata
A subtle issue emerges: after decoration, the function's metadata (__name__, __doc__, etc.) reflects the wrapper, not the original. The functools.wraps decorator solves this:
from functools import wraps
def with_logging(func):
@wraps(func)
def inner(*args, **kwargs):
print(f"Executing {func.__name__}")
return func(*args, **kwargs)
return inner
@with_logging
def calculate(x):
"""Computes the square of a number."""
return x ** 2
print(calculate.__name__) # Outputs: calculate
print(calculate.__doc__) # Outputs: Computes the square of a number.
Without wraps, these would return inner and None respectively.
Stacking Multiple Decorators
Functions can have multiple decoraotrs. They stack from bottom to top in terms of definition, but execute from top to bottom:
def bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def text(content):
return content
print(text("Hello")) # Outputs: <b><i>Hello</i></b>
The equivalent unwrapped form is text = bold(italic(text)). Execution flows from italic (innermost) to bold (outermost).
Practical Applications
Decorators excel at cross-cutting concerns that span multiple functions:
- Caching: Store results of expensive computations
- Authentication: Verify user credentials before execution
- Retry logic: Automatically retry failed operations
- Rate limiting: Control execution frequency
These patterns separate auxiliary logic from core business code, keeping functions focused and testable.
Key Takeaways
Decorators wrap functions to extend behavior without modifying their source. They leverage Python's ability to treat functions as objects that can be passed around and returned. The @ syntax provides clean, readable decoration, while functools.wraps maintains proper metadata. Both function-based and class-based decorators serve different use cases, with class decorators offering better state management when needed.