⚡IntermediateFunctional Python
Decorators
Wrapping functions to add behaviour with the @decorator syntax logging, timing, caching, and more.
Overview
A decorator is a function that takes another function as input, wraps it with additional behaviour, and returns the wrapped function. The `@decorator` syntax is just shorthand for `fn = decorator(fn)`.
Decorators are a direct application of Python's first-class functions since functions are objects, you can pass them as arguments, return them from other functions, and assign them to variables.
A typical decorator defines an inner `wrapper` function that calls the original function and adds behaviour before/after (or both). The `functools.wraps` decorator should always be applied to the wrapper to preserve the original function's name, docstring, and signature without it, debugging and introspection tools see the wrapper instead of the real function.
Common real-world uses: logging, timing/profiling, authentication checks, caching (`@functools.lru_cache`), retry logic, input validation, and rate limiting.
Key Points
- •`@decorator` is syntactic sugar for `fn = decorator(fn)`
- •A decorator is a function that takes a function and returns a (usually wrapped) function
- •Always apply `@functools.wraps(fn)` to the wrapper to preserve metadata
- •Decorators with arguments need an extra layer: `@retry(3)` → `decorator_factory(3)` returns a decorator
- •`@functools.lru_cache(maxsize=128)` built-in memoisation decorator
- •Multiple decorators stack bottom-up: `@a @b def f` → `f = a(b(f))`
- •Class decorators work too apply to a class to modify or wrap it
Examples
Basic decorator with functools.wraps
import functools
import time
def timer(fn):
@functools.wraps(fn) # preserves fn.__name__, fn.__doc__
def wrapper(*args, **kwargs):
start = time.time()
result = fn(*args, **kwargs)
elapsed = time.time() - start
print(f"{fn.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
print(slow_sum(1_000_000)) # prints time, then the result
print(slow_sum.__name__) # slow_sum (not wrapper, thanks to @wraps)Decorator with arguments (factory pattern)
import functools
def repeat(times): # outer function takes the argument
def decorator(fn): # middle function takes the function
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
result = fn(*args, **kwargs)
return result
return wrapper
return decorator # returns the actual decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!Real-world examples: logging and lru_cache
import functools
def log_calls(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print(f"Calling {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
print(f"{fn.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 5)
# Calling add((3, 5), {})
# add returned 8
# Built-in memoisation decorator
@functools.lru_cache(maxsize=128)
def fib(n):
if n < 2: return n
return fib(n - 1) + fib(n - 2)
print(fib(50)) # fast, thanks to cachingCommon Mistakes
- Forgetting @functools.wraps your function loses its __name__ and __doc__, breaking help(), logging, and stack traces
- Calling the function in the decorator body instead of returning the wrapper: `return wrapper` not `return wrapper()`
- Decorators with arguments need three levels of nesting missing one level causes confusing TypeErrors
Note
Think of decorators as "plugins" for functions. They let you add cross-cutting concerns (logging, auth, timing) in one place without modifying every function that needs it. This is the Open/Closed principle open for extension, closed for modification.
Exercises
Write Python code to solve each exercise. Run it in your browser and check your output.
Loading editor…
Completed: 0 / 5 exercises
Knowledge Check
Question 1 of 4