Python Decorators Explained
A decorator is a function that takes another function as input and extends its behavior without explicitly modifying its code. Decorators let you wrap functions with reusable logic — like timing how long a function takes, logging its calls, or caching its results.
Basic Syntax
Python provides a special @ syntax for applying decorators. Before the syntax existed, you’d manually pass a function through a wrapper:
def my_function():
return "hello"
# Old way — works but clunky
wrapped = my_decorator(my_function)
The @ syntax does the same thing more cleanly:
@my_decorator
def my_function():
return "hello"
This is equivalent to my_function = my_decorator(my_function).
Your First Decorator
Here’s a decorator that prints a message before and after a function runs:
def trace(func):
def wrapper():
print("Starting")
func()
print("Finished")
return wrapper
@trace
def say_hello():
print("Hello, world!")
say_hello()
# Starting
# Hello, world!
# Finished
The outer trace function accepts the original function. The inner wrapper adds the extra behavior. trace returns the wrapper, which replaces the original function.
Preserving Function Metadata
The wrapper function hides the original function’s name, docstring, and other attributes:
def trace(func):
def wrapper():
print("Calling", func.__name__)
return func()
return wrapper
@trace
def greet(name):
"""Greets the user."""
return f"Hello, {name}"
print(greet.__name__) # wrapper — wrong!
print(greet.__doc__) # None — wrong!
Python 3 added functools.wraps to copy the original function’s metadata to the wrapper:
import functools
def trace(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("Calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@trace
def greet(name):
"""Greets the user."""
return f"Hello, {name}"
print(greet.__name__) # greet — correct!
print(greet.__doc__) # Greets the user. — correct!
Always use @functools.wraps(func) in your decorators. It costs nothing and prevents debugging headaches.
Decorators with Arguments
Sometimes you need to pass configuration to a decorator — like specifying how many times to retry a function, or which logger to use. This requires a third level of nesting.
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}")
greet("Alice")
# Hello, Alice
# Hello, Alice
# Hello, Alice
The flow is: repeat(times=3) returns decorator, which is then applied to greet.
Practical Example: Timing a Function
Here’s a decorator that measures how long a function takes to run:
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(0.5)
return "done"
slow_function()
# slow_function took 0.5012 seconds
Practical Example: Caching Results
The functools.lru_cache decorator caches a function’s return values based on its arguments:
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # Fast — cached!
print(fibonacci.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
Without caching, computing fibonacci(100) would take astronomically longer. With it, the function runs once per unique argument and returns the cached result thereafter.
Practical Example: Logging Function Calls
import functools
import logging
logging.basicConfig(level=logging.INFO)
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__}")
try:
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result}")
return result
except Exception as e:
logging.error(f"{func.__name__} raised {type(e).__name__}: {e}")
raise
return wrapper
@logged
def divide(a, b):
return a / b
divide(10, 2)
# INFO: Calling divide
# INFO: divide returned 5.0
Stacking Decorators
You can apply multiple decorators to a single function. They stack from bottom to top:
@timer
@logged
def process_data(data):
# ... processing ...
return data
The process_data function is first wrapped by @logged, then that result is wrapped by @timer. The outermost decorator (closest to the function name) runs first.
When to Use Decorators
Decorators are the right choice when:
- You want to add behavior to multiple functions without modifying each one
- The behavior is orthogonal to the function’s main purpose (timing, logging, caching)
- You need to configure the behavior at definition time
When Not to Use Decorators
Avoid decorators when:
- The behavior is specific to one function — just write it inside the function
- The wrapper changes the function’s signature in ways callers expect (document this clearly)
- You’re overusing them — too many decorators make code hard to trace
Decorators are powerful, but they add a layer of indirection. Use them when the reuse benefit outweighs the complexity cost.
Class-Based Decorators
Decorators can also be classes. The class must implement call to be invoked when the decorated function is called:
class CountCalls:
def __init__(self, func):
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
print(f"{self.func.__name__} called {self.calls} times")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
return "Hello"
say_hello() # says Hello
say_hello() # says Hello
Class-based decorators are useful when you need to maintain state across multiple calls.
Real-World Use Cases
Decorators appear everywhere in production Python code:
- Flask routes: @app.route registers URL handlers
- Django views: @login_required checks authentication
- pytest: @pytest.fixture sets up test resources
- FastAPI: @app.get and @app.post define API endpoints
- Retry logic: Libraries like tenacity use decorators for retry policies
Understanding decorators helps you read and write code across the Python ecosystem.