Introduction
Decorators are a powerful Python mechanism for adding functionality to existing functions or classes without modifying their behavior. Applied declaratively with the @ syntax, they are widely used for cross-cutting concerns such as logging, timing, retry logic, and caching.
This article covers decorator fundamentals through practical patterns with code examples.
Prerequisites: First-Class Functions and Closures
In Python, functions are objects that can be assigned to variables, passed to other functions, and returned from functions.
def greet(name):
return f"Hello, {name}"
say_hello = greet # Assign function to variable
print(say_hello("Alice")) # "Hello, Alice"
A closure is an inner function that references variables from the enclosing function’s scope, retaining access even after the outer function returns.
def make_multiplier(factor):
def multiplier(x):
return x * factor # References factor (closure)
return multiplier
double = make_multiplier(2)
print(double(5)) # 10
Basic Decorator Pattern
A decorator is a “function that takes a function and returns a function.”
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@my_decorator
def add(a, b):
return a + b
# @my_decorator is equivalent to add = my_decorator(add)
print(add(3, 4))
# Output:
# Calling add
# Finished add
# 7
The Importance of functools.wraps
Applying a decorator replaces the original function’s metadata (__name__, __doc__) with the wrapper’s. functools.wraps prevents this.
import functools
def my_decorator(func):
@functools.wraps(func) # Preserve original function metadata
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers"""
return a + b
print(add.__name__) # "add" (without wraps: "wrapper")
print(add.__doc__) # "Add two numbers"
Parameterized Decorators
When passing arguments to the decorator itself, a triple-nested structure is needed.
import functools
def repeat(n):
"""Decorator that repeats a function n times"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(n):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(3)
def greet(name):
return f"Hello, {name}"
print(greet("Alice")) # ["Hello, Alice", "Hello, Alice", "Hello, Alice"]
Practical Patterns
Execution Timer
import functools
import time
def timer(func):
"""Decorator to measure execution time"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "done"
slow_function() # "slow_function: 1.0012s"
Retry with Exponential Backoff
import functools
import time
def retry(max_attempts=3, base_delay=1.0):
"""Retry decorator with exponential backoff"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
delay = base_delay * (2 ** attempt)
print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, base_delay=0.5)
def unreliable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("API unavailable")
return {"status": "ok"}
Simple Cache
import functools
def simple_cache(func):
"""Decorator to cache results"""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@simple_cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # Impractical without caching
In practice, functools.lru_cache provides equivalent functionality:
@functools.lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
Class-Based Decorators
Classes implementing __call__ can also serve as decorators, useful when maintaining state.
import functools
class CountCalls:
"""Decorator that counts function invocations"""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello()
say_hello()
print(f"Called {say_hello.count} times") # "Called 2 times"
Stacking Decorators
Multiple decorators are applied bottom-up and executed top-down.
@timer
@retry(max_attempts=2)
def api_call():
pass
# Equivalent: api_call = timer(retry(max_attempts=2)(api_call))
# Execution order: timer → retry → api_call
Built-in Decorators
Key standard library decorators:
| Decorator | Purpose |
|---|---|
@property | Access methods as properties |
@staticmethod | Methods without instance reference |
@classmethod | Methods receiving class as first argument |
@functools.lru_cache | Memoization cache |
@functools.wraps | Preserve metadata in decorators |
@dataclasses.dataclass | Auto-generate data classes |
Related Articles
- How to Overwrite Print Output in Python - Practical Python tips.
- Creating 3D Animations (GIF) with Python Matplotlib - Practical Matplotlib usage.
- Python Environment Setup - Setting up Python development environments.
References
- Python official documentation: functools
- Ramalho, L. (2022). Fluent Python (2nd ed.). O’Reilly Media. Chapter 9: Decorators and Closures.
- PEP 318 – Decorators for Functions and Methods