Modern Python Intermediate #5: Decorator patterns
@contextmanager from #4 Iterables and generators, @dataclass from #1, the standard library’s @functools.cache, FastAPI’s @app.get(...) — all decorators. This post organizes every way to build them yourself.
Decorator = a function that wraps a function #
def log_calls(fn):
def wrapper(*args, **kwargs):
print(f"호출: {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
print(f"반환: {result}")
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
return a + b
add(2, 3)
# 호출: add((2, 3), {})
# 반환: 5Putting @log_calls above the function definition is just syntactic sugar for:
def add(a: int, b: int) -> int:
return a + b
add = log_calls(add)A decorator takes a function and returns a new function. The original name is bound to the new function as a result.
First pitfall — losing metadata #
The add above is no longer add.
@log_calls
def add(a: int, b: int) -> int:
"""두 정수를 더한다."""
return a + b
print(add.__name__) # 'wrapper' ← !
print(add.__doc__) # NoneWhat solves this is functools.wraps.
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"호출: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""두 정수를 더한다."""
return a + b
print(add.__name__) # 'add'
print(add.__doc__) # '두 정수를 더한다.'The single line @wraps(fn) copies name, docstring, annotations, and signature from the original. Almost always add it when writing a custom decorator.
Decorators that take arguments #
To pass arguments to the decorator itself like @retry(times=3), one more level is added.
@retry
def request(): ...
# Behavior: request = retry(request)@retry(times=3)
def request(): ...
# Behavior: request = retry(times=3)(request)
# ^^^^^^^^^^^^^^^^ this must return a decoratorThat is, retry(...) must be a function that returns a decorator. Three levels of nesting result.
from functools import wraps
def retry(times: int): # 1) take the argument
def decorator(fn): # 2) take the function
@wraps(fn)
def wrapper(*args, **kwargs): # 3) take the call
last_error = None
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_error = e
raise last_error
return wrapper
return decorator
@retry(times=3)
def fetch():
...How to read:
- Outermost
retry(times)— decorator argument - Middle
decorator(fn)— function to decorate - Innermost
wrapper(*args, **kwargs)— the actual call
The three levels look awkward but it’s the standard form for any parameterized decorator.
functools.partial trick
#
When you want both @retry() (no args) and @retry(3) to work.
from functools import wraps, partial
def retry(fn=None, *, times: int = 3):
if fn is None:
return partial(retry, times=times)
@wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception:
continue
raise
return wrapper
@retry
def a(): ...
@retry(times=5)
def b(): ...Complex enough that it’s not used often. Pick one form and be consistent.
Common standard decorators #
@functools.cache / @lru_cache — memoization
#
from functools import cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075 — instantIf called again with the same arguments, returns the cached result without computing. Use only on pure functions (not on functions with side effects).
@lru_cache(maxsize=128) is the version with a cache-size limit. Useful when you don’t want unbounded memory growth.
@property — call like an attribute
#
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self) -> float:
return self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12.0 ← attribute access, not a method call@staticmethod / @classmethod
#
class Math:
@staticmethod
def add(a: int, b: int) -> int:
return a + b
@classmethod
def from_pair(cls, pair: tuple[int, int]):
return cls(*pair)staticmethod — takes neither self nor cls. classmethod — takes the class as the first argument (often used for alternative constructors).
Class decorators #
Decorators also work on classes. Same as functions: a function that takes a class and returns a class.
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
print(User(1, "커티스"))
# User(id=1, name='커티스')@dataclass is exactly this pattern — takes a class and returns a class with __init__/__repr__/__eq__ added.
Decorator as a class — using an object on call #
You can use a class with __call__ as a decorator instead of a function. Fits when state is needed.
class CallCounter:
def __init__(self, fn):
self.fn = fn
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.fn(*args, **kwargs)
@CallCounter
def greet():
print("hi")
greet()
greet()
greet()
print(greet.count) # 3greet is now a CallCounter instance. Attribute access like .count works naturally. Drawback: preserving the function signature/metadata is tricky, and static analyzers can be finicky. If you don’t need state, function decorators are better.
Decorators on methods — self flows through
#
The wrapper inside the decorator takes *args, **kwargs, so the method’s self simply comes in as args[0].
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"호출: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
class Service:
@log_calls
def fetch(self, url: str):
return f"data from {url}"
s = Service()
s.fetch("https://api.example.com")
# 호출: fetchNo special handling needed. It just works.
Type hints for decorators — ParamSpec
#
Annotating decorators with types makes static analysis precise. But because decorators wrap arbitrary functions, preserving the type as-is is tricky. The tool that solves this is ParamSpec.
from typing import Callable
from collections.abc import Awaitable
def log_calls[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"호출: {fn.__name__}")
return fn(*args, **kwargs)
return wrapperWhat [**P, R] (3.12+) means:
P— the function’s parameter signature as-isR— the return type
With this, the decorated function’s signature is preserved. Caller-side autocomplete and validation are accurate. The old style:
from typing import TypeVar, ParamSpec, Callable
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapperNew code: write [**P, R] next to the function.
Composition — stacking decorators #
@log_calls
@retry(times=3)
@cache
def fetch(url): ...The above applies bottom-up.
fetch = log_calls(retry(times=3)(cache(fetch)))Order matters. In the example:
cachefirst — on a hit, retry doesn’t runretrynext — retries the actual call on a cache misslog_callsoutermost — logs every call including retries
Common patterns — gathered #
Timing #
import time
from functools import wraps
def timing[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__}: {elapsed:.3f}s")
return result
return wrapperPermission check #
def require_admin(fn):
@wraps(fn)
def wrapper(user, *args, **kwargs):
if not user.is_admin:
raise PermissionError("관리자만 가능")
return fn(user, *args, **kwargs)
return wrapper
@require_admin
def delete_user(actor, target_id): ...Web framework permission handling typically follows this pattern.
Result caching with TTL #
from functools import wraps
import time
def ttl_cache(seconds: float):
def decorator(fn):
cache_data: dict = {}
@wraps(fn)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.monotonic()
if key in cache_data:
value, ts = cache_data[key]
if now - ts < seconds:
return value
value = fn(*args, **kwargs)
cache_data[key] = (value, now)
return value
return wrapper
return decorator
@ttl_cache(seconds=60)
def get_config(): ...Wrap-up #
Patterns this post covered:
- Decorator = take a function, return a function;
@dis sugar forf = d(f) functools.wraps— preserve name/docstring/signature; almost always include- Parameterized decorators = 3-level nesting (args → function → call)
@cache/@lru_cache(memoization),@property(attribute-like),@staticmethod/@classmethod- Class decorators — take a class, return a class;
@dataclassis the example - Class-form decorators — instance with
__call__; for stateful needs - Decorators on methods just work —
*args, **kwargshandles it [**P, R](3.12+) —ParamSpec+TypeVarto preserve signatures- Multiple decorators apply bottom-up — order matters
In the next post (#6 Pattern matching in depth) we cover every pattern of match-case — class patterns, __match_args__, capture variables, and guard depth — building on what we briefly saw in Basics #3.