Chapter 22: Decorators
In Chapter 21 you built a memoize function that wrapped another function with a cache. You wrote it like this:
fast_fibonacci = memoize(slow_fibonacci)
That's a decorator — you just didn't use the special syntax yet. A decorator is a function that takes another function, wraps it with extra behavior, and returns the wrapped version. Python gives you the @ symbol to apply decorators cleanly.
This chapter shows you exactly how decorators work, how to write your own, how to stack them, how to pass arguments to them, and where they show up constantly in real Python code.
The Long Way First
Before the @ syntax, let's see the mechanics explicitly:
def shout(func):
"""Wrap func so it prints 'START' before and 'DONE' after."""
def wrapper(*args, **kwargs):
print("START")
result = func(*args, **kwargs)
print("DONE")
return result
return wrapper
def greet(name):
print(f"Hello, {name}!")
# Manually wrap
greet = shout(greet)
greet("Alice")
Output:
START
Hello, Alice!
DONE
shout takes a function, defines an inner wrapper that adds behavior before and after the original call, and returns wrapper. After greet = shout(greet), greet is the wrapper. The original function is captured inside the closure.
The @ Syntax
The @decorator line is exactly equivalent to func = decorator(func). It's just cleaner:
@shout
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Output:
START
Hello, Alice!
DONE
Same result. Python processes @shout before the function is bound to any name. When Python sees:
@shout
def greet(name):
...
it runs this under the hood:
def greet(name):
...
greet = shout(greet)
That's the complete rule. No magic beyond that.
Preserving the Wrapped Function's Identity
There's one problem with naive decorators. After wrapping, the function loses its name and docstring:
def shout(func):
def wrapper(*args, **kwargs):
print("START")
result = func(*args, **kwargs)
print("DONE")
return result
return wrapper
@shout
def greet(name):
"""Greet someone warmly."""
print(f"Hello, {name}!")
print(greet.__name__) # wrapper <- wrong!
print(greet.__doc__) # None <- wrong!
Fix this with functools.wraps:
import functools
def shout(func):
@functools.wraps(func) # copies __name__, __doc__, __module__, etc.
def wrapper(*args, **kwargs):
print("START")
result = func(*args, **kwargs)
print("DONE")
return result
return wrapper
@shout
def greet(name):
"""Greet someone warmly."""
print(f"Hello, {name}!")
print(greet.__name__) # greet [x]
print(greet.__doc__) # Greet someone warmly. [x]
Always use @functools.wraps(func) inside your wrapper. It's one line, and it makes debugging, logging, and introspection work correctly. Consider it mandatory.
Useful Decorator Patterns
Timing
import functools
import time
def timer(func):
"""Print how long the function took to run."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__!r} took {end - start:.6f}s")
return result
return wrapper
@timer
def slow_sum(n):
"""Sum integers from 0 to n."""
return sum(range(n))
print(slow_sum(10_000_000))
Output:
'slow_sum' took 0.312471s
3333333350000000
Logging
import functools
def log_calls(func):
"""Log every call: arguments in, result out."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kwargs_repr)
print(f"CALL {func.__name__}({signature})")
result = func(*args, **kwargs)
print(f"RETURN {func.__name__} -> {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
@log_calls
def greet(name, loud=False):
message = f"Hello, {name}!"
return message.upper() if loud else message
add(3, 4)
greet("Alice")
greet("Bob", loud=True)
Output:
CALL add(3, 4)
RETURN add -> 7
CALL greet('Alice')
RETURN greet -> 'Hello, Alice!'
CALL greet('Bob', loud=True)
RETURN greet -> 'HELLO, BOB!'
Retry on failure
import functools
import time
def retry(times=3, delay=0, exceptions=(Exception,)):
"""
Retry the function up to `times` times if it raises one of `exceptions`.
Wait `delay` seconds between retries.
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_error = e
print(f"Attempt {attempt}/{times} failed: {e}")
if attempt < times and delay:
time.sleep(delay)
raise last_error
return wrapper
return decorator
import random
@retry(times=5, exceptions=(ValueError,))
def unstable_parse(text):
"""Simulate a parser that sometimes fails."""
if random.random() < 0.7:
raise ValueError("Parse error (simulated)")
return int(text)
try:
result = unstable_parse("42")
print(f"Result: {result}")
except ValueError:
print("All retries exhausted.")
Notice that retry is a decorator factory — a function that returns a decorator. The three levels are:
retry(times=5)— outer function, accepts configuration, returnsdecoratordecorator(func)— middle function, accepts the function, returnswrapperwrapper(*args, **kwargs)— inner function, runs when the decorated function is called
Input validation
import functools
def validate_positive(*param_names):
"""Raise ValueError if any named parameter is not positive."""
def decorator(func):
import inspect
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name in param_names:
if name in bound.arguments:
value = bound.arguments[name]
if value <= 0:
raise ValueError(
f"Parameter {name!r} must be positive, got {value}."
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_positive("width", "height")
def make_rectangle(width, height):
return width * height
print(make_rectangle(4, 5)) # 20
try:
make_rectangle(-1, 5)
except ValueError as e:
print(e) # Parameter 'width' must be positive, got -1.
Caching (your own lru_cache)
import functools
def simple_cache(func):
"""Cache results in a plain dictionary (no size limit)."""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.cache_info = lambda: f"{len(cache)} items cached"
wrapper.cache_clear = lambda: cache.clear()
return wrapper
@simple_cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # 12586269025
print(wrapper.cache_info()) # <- AttributeError (wrapper is not in scope)
print(fibonacci.cache_info()) # 51 items cached
For production use, prefer @functools.lru_cache(maxsize=128) — it's faster, thread-safe, and has a size limit to prevent unbounded memory growth.
Decorator Factories (Decorators with Arguments)
A plain decorator takes one argument — the function. When you need to pass configuration to the decorator, you need an extra level of nesting:
# Pattern:
def decorator_factory(config):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# use config here
return func(*args, **kwargs)
return wrapper
return decorator
@decorator_factory(config_value)
def my_function():
...
The @decorator_factory(config_value) line calls decorator_factory(config_value) first, gets back decorator, and then applies decorator to my_function.
Rate limiter
import functools
import time
from collections import deque
def rate_limit(calls_per_second):
"""Allow at most `calls_per_second` calls per second."""
min_interval = 1.0 / calls_per_second
last_called = [0.0] # list so closure can mutate it
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
to_wait = min_interval - elapsed
if to_wait > 0:
time.sleep(to_wait)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def fetch_data(url):
print(f"Fetching {url} at {time.time():.3f}")
for url in ["http://a.com", "http://b.com", "http://c.com"]:
fetch_data(url)
Output (approximately):
Fetching http://a.com at 1741500001.234
Fetching http://b.com at 1741500001.734
Fetching http://c.com at 1741500002.234
Each call waits at least 0.5s — two calls per second maximum.
Stacking Decorators
You can apply multiple decorators to one function. They apply bottom-up (innermost first):
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
def underline(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<u>{func(*args, **kwargs)}</u>"
return wrapper
@bold
@italic
@underline
def greet(name):
return f"Hello, {name}!"
print(greet("Alice"))
Output:
<b><i><u>Hello, Alice!</u></i></b>
The order is: greet -> underline(greet) -> italic(underline(greet)) -> bold(italic(underline(greet))).
When called, the outermost decorator runs first: bold wraps italic wraps underline wraps greet.
A more practical stacking example:
@timer
@log_calls
@retry(times=3)
def call_api(endpoint):
# would normally make an HTTP request
import random
if random.random() < 0.5:
raise ConnectionError("Network timeout")
return {"status": "ok", "endpoint": endpoint}
Execution order: timer starts the clock -> log_calls logs the call -> retry handles failures -> call_api runs.
Class-Based Decorators
You can also implement a decorator as a class that implements __call__:
import functools
class CountCalls:
"""Decorator that counts how many times a function is called."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.func.__name__!r} called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello()
say_hello()
say_hello()
print(say_hello.count) # 3
Output:
'say_hello' called 1 time(s)
Hello!
'say_hello' called 2 time(s)
Hello!
'say_hello' called 3 time(s)
Hello!
3
Class-based decorators are useful when the decorator needs to maintain significant state or have methods of its own. They're less common than function-based decorators but good to know.
Decorators in the Real World
You've already used decorators — you just may not have realized it. Every time you've written @property, @staticmethod, @classmethod, or @functools.lru_cache, you've been using a decorator.
Here's a quick map of decorators you'll encounter constantly:
| Decorator | Where | Purpose |
|---|---|---|
@property |
Classes | Turn a method into a read-only attribute |
@staticmethod |
Classes | Method that doesn't receive self or cls |
@classmethod |
Classes | Method that receives cls instead of self |
@functools.lru_cache |
Anywhere | Memoize with a size limit |
@functools.wraps |
Inside decorators | Copy metadata from wrapped function |
@dataclasses.dataclass |
Classes | Auto-generate __init__, __repr__, etc. |
@abstractmethod |
ABCs | Mark method as required in subclasses |
@app.route("/") |
Flask/FastAPI | Register a URL route |
@pytest.mark.parametrize |
Tests | Run test with multiple input sets |
@login_required |
Django/Flask | Restrict view to logged-in users |
@app.on_event("startup") |
FastAPI | Run code on server startup |
Once you understand how decorators work, all of these become obvious.
Project: A Decorator Toolkit
Let's build a small, reusable collection of decorators that you'd actually use in a real project.
"""
toolkit.py — A collection of practical decorators.
"""
import functools
import time
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# ── 1. Timer ──────────────────────────────────────────────────────────────────
def timer(func):
"""Log execution time."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
ms = (time.perf_counter() - start) * 1000
logger.info(f"{func.__name__} completed in {ms:.2f}ms")
return result
return wrapper
# ── 2. Retry ──────────────────────────────────────────────────────────────────
def retry(times=3, delay=0.5, exceptions=(Exception,), on_retry=None):
"""Retry function up to `times` times on `exceptions`."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
if on_retry:
on_retry(attempt, e)
if attempt < times:
time.sleep(delay * attempt) # exponential back-off
raise last_exc
return wrapper
return decorator
# ── 3. Cache (with TTL) ───────────────────────────────────────────────────────
def cache(ttl_seconds=None):
"""Cache results. Expire entries after ttl_seconds if given."""
def decorator(func):
store = {} # {args: (result, timestamp)}
@functools.wraps(func)
def wrapper(*args):
now = time.monotonic()
if args in store:
result, ts = store[args]
if ttl_seconds is None or (now - ts) < ttl_seconds:
return result
result = func(*args)
store[args] = (result, now)
return result
wrapper.cache_clear = store.clear
return wrapper
return decorator
# ── 4. Deprecated ─────────────────────────────────────────────────────────────
def deprecated(message=""):
"""Emit a warning when the decorated function is called."""
import warnings
def decorator(func):
msg = f"{func.__name__} is deprecated. {message}".strip()
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(msg, DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
return wrapper
return decorator
# ── 5. Require types ──────────────────────────────────────────────────────────
def require_types(**type_map):
"""
Validate parameter types at call time.
Usage: @require_types(name=str, age=int)
"""
def decorator(func):
import inspect
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for param, expected in type_map.items():
if param in bound.arguments:
value = bound.arguments[param]
if not isinstance(value, expected):
raise TypeError(
f"{func.__name__}(): parameter {param!r} expected "
f"{expected.__name__}, got {type(value).__name__}."
)
return func(*args, **kwargs)
return wrapper
return decorator
# ── Demo ──────────────────────────────────────────────────────────────────────
@timer
@retry(times=3, delay=0.1)
def fetch_user(user_id):
import random
if random.random() < 0.5:
raise ConnectionError("Simulated network error")
return {"id": user_id, "name": "Alice"}
@cache(ttl_seconds=5)
def get_config(key):
print(f" (computing config for {key!r})")
return f"value_for_{key}"
@require_types(name=str, age=int)
def register(name, age):
print(f"Registered: {name}, age {age}")
@deprecated("Use register() instead.")
def add_user(name):
print(f"Adding user: {name}")
if __name__ == "__main__":
# Timer + retry
try:
user = fetch_user(42)
print(f"Got user: {user}")
except ConnectionError as e:
print(f"Failed after 3 retries: {e}")
print()
# Cache with TTL
print(get_config("theme")) # computed
print(get_config("theme")) # cached
print(get_config("language")) # computed
print()
# Type checking
register("Alice", 30)
try:
register("Bob", "thirty")
except TypeError as e:
print(e)
print()
# Deprecated
import warnings
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
add_user("Carol")
if w:
print(f"Warning: {w[0].message}")
What You Learned in This Chapter
- A decorator is a function that wraps another function with extra behavior.
@decoratoris shorthand forfunc = decorator(func).- Always use
@functools.wraps(func)inside your wrapper to preserve__name__and__doc__. - Common patterns: timing, logging, retry, caching, input validation.
- A decorator factory adds an extra nesting level so you can pass configuration to the decorator.
- Stacking decorators works bottom-up: the bottom decorator applies first, the top runs outermost.
- Class-based decorators implement
__call__and are useful for decorators with significant internal state. - Decorators are everywhere:
@property,@classmethod,@lru_cache,@app.route,@login_required.
What's Next?
Chapter 23 covers Generators and Iterators — the machinery that powers for loops, zip(), enumerate(), comprehensions, and lazy pipelines. You'll write your own range, your own zip, and your own infinite sequences. If you've ever wondered how Python loops work at the lowest level, this is where you find out.