Decorators
Comprehensive guide on Decorators.
Decorators
Overview#
A decorator is a function that takes another function (or class) and extends or modifies its behavior without permanently changing its source code. Decorators are one of Python's most powerful features for eliminating repetitive code — logging, timing, access control, caching, and validation are all commonly implemented as decorators. To really understand them, you first need to understand that in Python, functions are objects that can be passed around like any other value. This notebook builds decorators from first principles: functions as objects, closures, basic decorators, decorators with arguments, decorating classes, and the standard library's most useful decorator tools.
1. Functions Are First-Class Objects#
In Python, functions can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures — exactly like integers or strings.
Python 3.12 (WASM)def greet(name):
return f"Hello, {name}!"
say_hello = greet # assign the function itself, not its result
print(say_hello("Alice")) # Hello, Alice!
def call_twice(func, arg):
return func(arg) + " " + func(arg)
print(call_twice(greet, "Bob")) # Hello, Bob! Hello, Bob!
2. Nested Functions and Closures#
A function defined inside another function can access variables from the enclosing function's scope, even after the outer function has finished running. This is called a closure.
Python 3.12 (WASM)def make_multiplier(factor):
def multiply(number):
return number * factor # factor is "closed over" from the outer scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
Each call to make_multiplier creates a new, independent closure — double
and triple each remember their own factor value.
3. Building a Decorator From Scratch#
A decorator is simply a function that takes a function as input and returns a new function that wraps it.
Python 3.12 (WASM)def my_decorator(func):
def wrapper():
print("Before the function runs")
func()
print("After the function runs")
return wrapper
def say_hello():
print("Hello!")
say_hello = my_decorator(say_hello) # manually "decorate" say_hello
say_hello()
# Before the function runs
# Hello!
# After the function runs
The @ syntax#
The line say_hello = my_decorator(say_hello) is exactly what the @
syntax does automatically.
Python 3.12 (WASM)def my_decorator(func):
def wrapper():
print("Before the function runs")
func()
print("After the function runs")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello() # identical output to before — @my_decorator IS the assignment
4. Handling Arguments and Return Values#
The example above only works for functions with no arguments and no
return value. A general-purpose decorator uses *args and **kwargs to
accept and forward any arguments, and returns the wrapped function's
result.
Python 3.12 (WASM)def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@logger
def add(a, b):
return a + b
result = add(3, 5)
# Calling add with args=(3, 5), kwargs={}
# add returned 8
print(result) # 8
5. Preserving Function Metadata with functools.wraps#
Without extra care, a decorated function loses its original name,
docstring, and other metadata — wrapper replaces add entirely as far
as introspection is concerned.
Python 3.12 (WASM)def logger(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
"""Add two numbers together."""
return a + b
print(add.__name__) # wrapper -- WRONG, hides the real function's identity
print(add.__doc__) # None -- lost the original docstring
functools.wraps fixes this by copying the original function's metadata
onto the wrapper.
Python 3.12 (WASM)from functools import wraps
def logger(func):
@wraps(func) # preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
"""Add two numbers together."""
return a + b
print(add.__name__) # add -- correct
print(add.__doc__) # Add two numbers together.
Always use functools.wraps in any decorator you write — omitting it
is one of the most common decorator bugs, and it silently breaks tools
that rely on introspection, like documentation generators and debuggers.
6. Decorators with Their Own Arguments#
Sometimes you want to configure a decorator, e.g. @retry(times=3). This
requires an extra layer of nesting: a function that takes the decorator's
arguments and returns the actual decorator.
Python 3.12 (WASM)from functools import wraps
import time
def retry(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt == times:
raise
time.sleep(1)
return wrapper
return decorator
@retry(times=3)
def unstable_operation():
import random
if random.random() < 0.7:
raise ConnectionError("Network glitch")
return "Success"
unstable_operation()
Here, retry(times=3) runs first and returns decorator, which then gets
applied to unstable_operation exactly like a normal decorator.
7. Stacking Multiple Decorators#
Decorators can be stacked; they apply from the bottom up, closest to the function first.
Python 3.12 (WASM)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 greet(name):
return f"Hello, {name}"
print(greet("Alice")) # <b><i>Hello, Alice</i></b>
italic wraps greet first (closest to the function), then bold wraps
the result — so the order in the output is bold(italic(greet(...))).
8. Class-Based Decorators#
A class can act as a decorator if it implements __call__, making
instances of it callable like functions. This is useful when the
decorator needs to maintain state across calls.
Python 3.12 (WASM)class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call #{self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("Hi!")
say_hi() # Call #1 to say_hi / Hi!
say_hi() # Call #2 to say_hi / Hi!
print(say_hi.count) # 2
9. Decorating Classes#
Decorators aren't limited to functions — they can also be applied to entire classes, receiving the class itself as the argument.
Python 3.12 (WASM)def add_greeting(cls):
cls.greet = lambda self: f"Hello from {cls.__name__}"
return cls
@add_greeting
class Robot:
pass
r = Robot()
print(r.greet()) # Hello from Robot
@dataclass, seen in the OOP notebook, is a well-known real-world example
of a class decorator.
10. Useful Built-in and functools Decorators#
| Decorator | Purpose |
|---|---|
@property | Turn a method into a computed attribute (see OOP notebook) |
@staticmethod | Method that doesn't need self or cls |
@classmethod | Method that receives the class (cls) instead of an instance |
@functools.lru_cache | Cache a function's results based on its arguments |
@functools.wraps | Preserve metadata when writing decorators |
@functools.singledispatch | Function overloading based on argument type |
Caching expensive calls with lru_cache#
Python 3.12 (WASM)from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(35)) # computed almost instantly thanks to caching
print(fibonacci.cache_info()) # shows hits, misses, cache size
Without lru_cache, naive recursive Fibonacci recomputes the same values
exponentially many times; caching turns it from exponential to linear
time by remembering previous results.
Type-based dispatch with singledispatch#
Python 3.12 (WASM)from functools import singledispatch
@singledispatch
def describe(value):
return f"A value: {value}"
@describe.register
def _(value: int):
return f"An integer: {value}"
@describe.register
def _(value: list):
return f"A list with {len(value)} items"
print(describe(42)) # An integer: 42
print(describe([1, 2, 3])) # A list with 3 items
print(describe(3.14)) # A value: 3.14 (falls back to the default)
11. Common Real-World Decorator Patterns#
Python 3.12 (WASM)from functools import wraps
import time
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:.4f}s")
return result
return wrapper
def require_positive(func):
@wraps(func)
def wrapper(n, *args, **kwargs):
if n <= 0:
raise ValueError(f"{func.__name__} requires a positive number")
return func(n, *args, **kwargs)
return wrapper
@timer
@require_positive
def compute_factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
print(compute_factorial(10))
Notes#
- A decorator runs once, at definition time, to produce the wrapped
function — the returned
wrapperis what actually executes on every call afterward. - Always accept
*args, **kwargsin a general-purpose decorator's wrapper unless you deliberately want to restrict the wrapped function's signature. - Forgetting
functools.wrapsdoesn't cause an error, but silently corrupts introspection (__name__,__doc__) — a subtle bug that's easy to miss until debugging tools or documentation break. - Decorators that take arguments require one extra layer of function nesting compared to plain decorators — this trips up almost everyone the first time.
lru_cacheshould only be used on pure functions — those whose output depends only on their input, with no side effects — since cached calls don't re-execute the function body at all.
Best Practices Checklist#
- Always use
functools.wrapswhen writing a decorator - Accept
*args, **kwargsin the wrapper unless intentionally restricting arguments - Return the wrapped function's result from the wrapper, don't silently discard it
- Add an extra nesting layer only when the decorator itself needs configuration arguments
- Keep decorator logic focused on one concern (logging, timing, caching) rather than mixing multiple responsibilities
- Reach for
functools.lru_cachefor pure, expensive, repeatedly called functions instead of hand-rolled caching - Use
functools.singledispatchinstead of longisinstancechains when behavior should vary by argument type - Remember stacked decorators apply bottom-up, closest to the function first