Modern Python Intermediate #5: Decorator patterns

4 min read

@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 #

The simplest decorator
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), {})
# 반환: 5

Putting @log_calls above the function definition is just syntactic sugar for:

What it actually does
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.

Name and docstring disappear
@log_calls
def add(a: int, b: int) -> int:
    """두 정수를 더한다."""
    return a + b

print(add.__name__)   # 'wrapper'  ← !
print(add.__doc__)    # None

What solves this is functools.wraps.

✅ Preserve metadata with 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.

🚫 Decorator without arguments
@retry
def request(): ...

# Behavior: request = retry(request)
✅ Decorator with arguments
@retry(times=3)
def request(): ...

# Behavior: request = retry(times=3)(request)
#                     ^^^^^^^^^^^^^^^^ this must return a decorator

That is, retry(...) must be a function that returns a decorator. Three levels of nesting result.

3-level nesting
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.

Support both
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 #

cache
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 — instant

If 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 #

property
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 #

static / class method
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.

Class decorator example
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-form decorator
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)   # 3

greet 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].

Methods work too
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")
# 호출: fetch

No 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.

ParamSpec (3.10+)
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 wrapper

What [**P, R] (3.12+) means:

  • P — the function’s parameter signature as-is
  • R — the return type

With this, the decorated function’s signature is preserved. Caller-side autocomplete and validation are accurate. The old style:

Old style — don't use in new code
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 wrapper

New code: write [**P, R] next to the function.

Composition — stacking decorators #

Multiple decorators
@log_calls
@retry(times=3)
@cache
def fetch(url): ...

The above applies bottom-up.

Equivalent transformation
fetch = log_calls(retry(times=3)(cache(fetch)))

Order matters. In the example:

  1. cache first — on a hit, retry doesn’t run
  2. retry next — retries the actual call on a cache miss
  3. log_calls outermost — logs every call including retries

Common patterns — gathered #

Timing #

@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 wrapper

Permission check #

@require_admin
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 #

TTL cache
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; @d is sugar for f = 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; @dataclass is the example
  • Class-form decorators — instance with __call__; for stateful needs
  • Decorators on methods just work — *args, **kwargs handles it
  • [**P, R] (3.12+) — ParamSpec + TypeVar to 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.

X