A decorator is a callable that takes a function (or class) and returns a new one. The syntax @decorator placed above a def is shorthand for f = decorator(f). Decorators are how Python lets you add cross-cutting behavior — logging, timing, caching, retry, authentication — to existing functions without modifying their code.
A typical decorator is a function that defines an inner wrapper function. The wrapper does whatever extra work you want, then calls the original function and returns its result. Wrapping the wrapper with @functools.wraps preserves the original name, docstring, and signature — a small detail that saves a lot of debugging later.
The standard library ships several useful decorators. @functools.lru_cache memoizes a pure function. @functools.cache (Python 3.9+) is the unbounded version. @property makes a method look like an attribute. @classmethod and @staticmethod change how a method receives self/cls. Together they cover most decorator uses you will see.
Parameterized decorators (@retry(attempts=3)) are decorators that return decorators: the outer call configures the behavior, the inner call wraps the function. That two-level structure looks odd at first; once you see a few examples it becomes second nature.
Writing a simple decorator
def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print(f"calling {func.__name__}"); return func(*args, **kwargs); return wrapper. Apply with @log_calls. The wrapper accepts *args, **kwargs so it works with any signature.
Always use @functools.wraps(func) on the inner wrapper. Without it, help(my_func), inspect.signature, and the function's __name__ refer to the wrapper instead of the decorated function — confusing for everyone.
Parameterized decorators and class decorators
For a decorator that takes arguments, add one more level: def retry(attempts): def outer(func): def wrapper(...): ...; return wrapper; return outer. Apply with @retry(attempts=3).
Decorators also work on classes. @dataclass is the most famous example: it takes a class, reads its annotations, and returns a new class with __init__, __repr__ and __eq__ filled in.
Decorator building blocks.
| Tool | Purpose |
|---|---|
@functools.wrapsdecorator | Preserve __name__, __doc__, signature. |
@lru_cachedecorator | Memoize a pure function. |
@cachedecorator | Unbounded memoization (3.9+). |
functools.partialfunction | Bind some arguments in advance. |
@singledispatchdecorator | Type-based function dispatch. |
@dataclassdecorator | Class-level decorator; generates methods. |
@contextmanagerdecorator | Turn a generator into a context manager. |
PEP 318spec | Decorator syntax. |
Modifying Behavior Dynamically code example
The script builds a logging decorator, a timing decorator, a parameterized retry decorator, and uses lru_cache for memoization.
# Lesson: Modifying Behavior Dynamically
import time
from functools import lru_cache, wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"-> {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"<- {func.__name__} returned {result!r}")
return result
return wrapper
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
dur = (time.perf_counter() - start) * 1000
print(f"[timed] {func.__name__}: {dur:.2f} ms")
return wrapper
def retry(attempts: int = 3):
def outer(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_err = None
for i in range(1, attempts + 1):
try:
return func(*args, **kwargs)
except Exception as err:
print(f"[retry] attempt {i} failed: {err}")
last_err = err
raise RuntimeError(f"gave up after {attempts} attempts") from last_err
return wrapper
return outer
@log_calls
def greet(name: str) -> str:
return f"hello {name}"
@timed
@lru_cache(maxsize=None)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
count = {"n": 0}
@retry(attempts=3)
def flaky() -> str:
count["n"] += 1
if count["n"] < 3:
raise RuntimeError("transient")
return "ok"
print(greet("ana"))
print("fib(30):", fib(30))
print("flaky:", flaky())
print("greet.__name__:", greet.__name__) # preserved by @wraps
Study the layering:
1) `@wraps(func)` is the boilerplate that preserves metadata.
2) `@timed` and `@lru_cache` stack; order matters (innermost applies first).
3) `retry(attempts=3)` is a parameterized decorator — two levels of nesting.
4) `@lru_cache` turns exponential fib into linear without rewriting it.
Write a decorator that skips the body if a flag is False.
from functools import wraps
def enabled_if(flag: bool):
def outer(func):
@wraps(func)
def wrapper(*args, **kwargs):
if not flag:
return None
return func(*args, **kwargs)
return wrapper
return outer
@enabled_if(True)
def greet(name): return f"hello {name}"
@enabled_if(False)
def greet_silent(name): return f"hello {name}"
print(greet("ana")) # hello ana
print(greet_silent("ana")) # None
Check the metadata preservation contract.
from functools import wraps
def deco(func):
@wraps(func)
def wrapper(*a, **kw):
return func(*a, **kw)
return wrapper
@deco
def f(x): """doc"""; return x
assert f.__name__ == "f"
assert f.__doc__ == "doc"
Running prints something like:
-> greet(('ana',), {})
<- greet returned 'hello ana'
hello ana
[timed] fib: 0.02 ms
fib(30): 832040
[retry] attempt 1 failed: transient
[retry] attempt 2 failed: transient
flaky: ok
greet.__name__: greet