Python course menu
Python Decorators Explained Step by Step (with functools.wraps)
Learn Python decorators from the ground up: functions as objects, wrapper functions, the @ syntax, *args and **kwargs in wrappers, functools.wraps, decorators with arguments, and practical timing, logging, caching and validation decorators.
On this page
A decorator wraps a function to add behaviour before or after it runs, without changing the function's own code. Timing, logging, caching, permission checks and input validation are all commonly done with decorators. Web frameworks like Flask (@app.route) and testing tools (@pytest.fixture) use them heavily.
Step 1: functions are objects#
Functions can be stored in variables and passed to other functions:
def say(text):
return text
f = say
print(f("functions are objects"), say.__name__, f.__name__)
def apply(fn, value):
return fn(value)
print(apply(len, "hello"), apply(str.upper, "hi"))functions are objects say say
5 HIStep 2: a function that wraps another#
A decorator is a function that takes a function and returns a new one, usually an inner wrapper that calls the original:
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
def greet(name):
return f"hello, {name}"
greet = shout(greet) # replace greet with the wrapped version
print(greet("Ravi"))HELLO, RAVIwrapper accepts *args, **kwargs so it can pass along whatever arguments the original function takes (see Parameters). It remembers func thanks to a closure (see Scope).
Step 3: the @ syntax#
@decorator above a function definition is shorthand for func = decorator(func):
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper() + "!"
return wrapper
@shout
def greet(name):
return f"hello, {name}"
print(greet("Asha"))HELLO, ASHA!Always use functools.wraps#
A plain wrapper hides the original function's name and docstring:
def no_wraps(func):
def wrapper(*a, **k):
return func(*a, **k)
return wrapper
@no_wraps
def report():
"""Build the report."""
print(report.__name__, report.__doc__)wrapper None@functools.wraps(func) copies them over, so debugging tools, error messages and documentation still show the real function. Use it in every decorator.
A timing decorator#
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@timer
def slow_sum(n):
time.sleep(0.1)
return sum(range(n))
print(slow_sum(1000))
print(slow_sum.__name__)slow_sum took 0.101s
499500
slow_sum(The exact time varies slightly on each run.) Note that the wrapper returns the result; forgetting that is a classic bug.
A logging decorator#
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}{args} {kwargs or ''}".rstrip())
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
add(10, b=5)calling add(2, 3)
add returned 5
calling add(10,) {'b': 5}
add returned 15Decorators with arguments#
To pass options to a decorator, add one more layer: a function that takes the options and returns a decorator:
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return [func(*args, **kwargs) for _ in range(times)]
return wrapper
return decorator
@repeat(3)
def roll():
return "🎲"
print(roll())['🎲', '🎲', '🎲']@repeat(3) first calls repeat(3), which returns the real decorator.
Built-in decorators you will meet#
@functools.lru_cache: remembers results of previous calls (see Recursion).@property,@classmethod,@staticmethod: used in classes (see Encapsulation).@dataclass: generates class boilerplate (see Constructors).
from functools import lru_cache
import time
@lru_cache(maxsize=None)
def slow_square(n):
time.sleep(0.2)
return n * n
t = time.perf_counter(); slow_square(9); first = time.perf_counter() - t
t = time.perf_counter(); slow_square(9); second = time.perf_counter() - t
print(first > 0.15, second < 0.01, slow_square.cache_info().hits)True True 1The second call returned instantly from the cache.
Common mistakes#
Practice#
Practice exercise
Write a decorator require_positive that raises ValueError("<function name>: arguments must be positive") if any positional argument is zero or negative. Apply it to area(w, h) and call it with (3, 4) and (3, -1).
Show hint
In the wrapper, check any(a <= 0 for a in args) before calling func. Use func.__name__ in the message.
Show solution
from functools import wraps
def require_positive(func):
@wraps(func)
def wrapper(*args):
if any(a <= 0 for a in args):
raise ValueError(f"{func.__name__}: arguments must be positive")
return func(*args)
return wrapper
@require_positive
def area(w, h):
return w * h
print(area(3, 4))
try:
area(3, -1)
except ValueError as e:
print(e)12
area: arguments must be positiveThe validation is written once and can be reused on any function.
Key takeaway#
Finished this lesson?