Polyglot
Languages Python decorators
Python § decorators

Decorators

A decorator is a function that takes a function (or class) and returns a function (or class). The @decorator syntax admits compact application; the mechanism is one of Python’s most distinctive contributions and is pervasive in modern frameworks (Flask, FastAPI, Django, pytest, dataclasses, the standard library). Decorators admit cross-cutting concerns — logging, caching, validation, authentication, registration — to be applied without modifying the decorated function’s body. The combination of first-class functions, closures, and the @ syntax produces a substantial AOP-style mechanism without ceremony.

This page covers function decorators, decorator factories, class decorators, the standard-library decorators, and the conventional patterns. The deeper functional surface (functools, operator) is in Functional.

Function decorators

The simplest decorator wraps a function:

def with_logging(f):
    def wrapped(*args, **kwargs):
        print(f"calling {f.__name__}")
        result = f(*args, **kwargs)
        print(f"  → {result}")
        return result
    return wrapped

@with_logging
def square(n):
    return n * n

square(5)
# calling square
#   → 25
# 25

The @with_logging is equivalent to square = with_logging(square) after the function’s definition. The decorator:

  1. Takes the function square as its argument f.
  2. Defines an inner function wrapped that takes any arguments.
  3. The inner function calls f(*args, **kwargs) and adds behaviour.
  4. Returns wrapped; square is now bound to wrapped.

The wrapped function preserves the original signature through *args, **kwargs forwarding.

functools.wraps

The naive decorator above loses the original function’s name, docstring, and other metadata. The functools.wraps admits preserving them:

from functools import wraps

def with_logging(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
        print(f"calling {f.__name__}")
        return f(*args, **kwargs)
    return wrapped

@with_logging
def square(n):
    """Compute the square of n."""
    return n * n

square.__name__       # "square" (not "wrapped")
square.__doc__         # "Compute the square of n."

The @wraps(f) copies f’s metadata onto the wrapped function. The conventional discipline is to use @wraps in every decorator that wraps a function.

Decorator factories

A decorator factory is a function that returns a decorator. The pattern admits configurable decorators:

def repeat(n):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            for _ in range(n):
                result = f(*args, **kwargs)
            return result
        return wrapped
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}")

greet("alice")
# Hello, alice
# Hello, alice
# Hello, alice

The @repeat(3) is equivalent to greet = repeat(3)(greet). The repeat(3) returns a decorator (the inner decorator function); the decorator is then applied to greet.

The pattern is the conventional form for parameterised decorators — caching with TTL, retry with attempts, logging with level, etc.

Stacking decorators

Multiple decorators apply bottom-up:

@decorator_a
@decorator_b
@decorator_c
def f():
    pass

# Equivalent to:
f = decorator_a(decorator_b(decorator_c(f)))

The innermost (decorator_c) wraps first; the outermost (decorator_a) wraps last and is the final outer wrapper.

@with_logging
@with_timing
def compute():
    do_work()

# When called:
# 1. with_logging's wrapper runs (prints "calling")
# 2. with_timing's wrapper runs (records start)
# 3. compute's body runs
# 4. with_timing's wrapper records end
# 5. with_logging's wrapper prints the result

The conventional advice: apply decorators in an order that makes the layering explicit; logging tends to be outermost (so it logs the wrapped behaviour), caching tends to be innermost (so it caches the actual computation, not the cached result).

Built-in decorators

Several decorators ship with Python:

@property, @<name>.setter, @<name>.deleter

For computed attributes:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("negative")
        self._radius = value

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

Treated in Classes and inheritance.

@classmethod and @staticmethod

For methods that don’t take an instance:

class Date:
    @classmethod
    def today(cls):
        # cls is the class
        ...

    @staticmethod
    def is_valid(year, month, day):
        # no implicit first argument
        ...

Treated in Classes and inheritance.

@functools.lru_cache

A memoising cache:

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(30)        # computed once; subsequent calls hit the cache

The maxsize=None admits unbounded caching:

@lru_cache(maxsize=None)        # unbounded
def expensive(...):
    ...

@lru_cache                       # since 3.8: shorthand for maxsize=128
def cached(...):
    ...

Python 3.9 added @functools.cache (unbounded shorthand for lru_cache(maxsize=None)).

@functools.cached_property

A property that is computed once per instance and cached:

from functools import cached_property

class Document:
    def __init__(self, content):
        self.content = content

    @cached_property
    def word_count(self):
        # computed once per instance, on first access
        return len(self.content.split())

The cache lives in the instance’s __dict__; reads after the first are direct attribute access.

@functools.singledispatch

Type-based dispatch:

from functools import singledispatch

@singledispatch
def serialise(value):
    raise NotImplementedError

@serialise.register
def _(value: int):
    return str(value)

@serialise.register
def _(value: list):
    return "[" + ",".join(serialise(x) for x in value) + "]"

Treated in Functions.

@dataclass

Auto-generated __init__, __repr__, __eq__:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

Treated in Classes and inheritance.

@contextmanager

Convert a generator function to a context manager:

from contextlib import contextmanager

@contextmanager
def transaction(db):
    try:
        db.begin()
        yield db
        db.commit()
    except:
        db.rollback()
        raise

with transaction(database) as db:
    db.execute("INSERT ...")

Treated in Duck typing and protocols.

Class decorators

A decorator may take a class and return 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 Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
print(p)        # Point(x=3, y=4)

The @dataclass decorator is itself a class decorator; it adds __init__, __repr__, __eq__, etc.

The pattern admits adding cross-cutting behaviour to classes — registration, instrumentation, validation. Many frameworks use class decorators heavily.

Decorators with state

A decorator may maintain state across calls:

def call_counter(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
        wrapped.calls += 1
        return f(*args, **kwargs)
    wrapped.calls = 0
    return wrapped

@call_counter
def expensive():
    pass

expensive()
expensive()
expensive()
print(expensive.calls)        # 3

The wrapped.calls is an attribute on the wrapped function; the decorator initialises it.

For more elaborate state, a decorator class is the conventional alternative:

class CallCounter:
    def __init__(self, f):
        self.f = f
        self.calls = 0
        wraps(f)(self)

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.f(*args, **kwargs)

@CallCounter
def expensive():
    pass

expensive()
print(expensive.calls)        # 1

The __call__ admits the class instance to be called; the wraps(f)(self) copies f’s metadata.

Common patterns

Logging

def with_logging(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
        logger.info(f"calling {f.__name__}({args}, {kwargs})")
        try:
            result = f(*args, **kwargs)
            logger.info(f"  → {result}")
            return result
        except Exception as e:
            logger.error(f"  → exception: {e}")
            raise
    return wrapped

The pattern admits adding logging without modifying the function.

Timing

import time

def timed(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
        start = time.perf_counter()
        try:
            return f(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{f.__name__} took {elapsed:.3f}s")
    return wrapped

Validation

def validate_args(*types):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            for arg, expected in zip(args, types):
                if not isinstance(arg, expected):
                    raise TypeError(f"expected {expected}, got {type(arg)}")
            return f(*args, **kwargs)
        return wrapped
    return decorator

@validate_args(int, str)
def process(n, name):
    pass

process(42, "alice")        # OK
process("42", "alice")      # TypeError

The pattern admits runtime validation.

Retry

import time

def retry(attempts, delay=1):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            for attempt in range(attempts):
                try:
                    return f(*args, **kwargs)
                except Exception as e:
                    if attempt == attempts - 1:
                        raise
                    time.sleep(delay)
        return wrapped
    return decorator

@retry(attempts=3, delay=2)
def fetch_data():
    response = requests.get(url)
    response.raise_for_status()
    return response.json()

Memoisation

def memoize(f):
    cache = {}
    @wraps(f)
    def wrapped(*args):
        if args not in cache:
            cache[args] = f(*args)
        return cache[args]
    return wrapped

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

For most cases, @functools.lru_cache is preferable (it has bounded size, thread-safety, and statistics).

Registration

HANDLERS = {}

def handler(event_type):
    def decorator(f):
        HANDLERS[event_type] = f
        return f
    return decorator

@handler("click")
def handle_click(event):
    pass

@handler("key")
def handle_key(event):
    pass

The decorator registers the function in a global dispatch table. The pattern is the conventional Python form for plugin systems.

Authentication

def require_auth(f):
    @wraps(f)
    def wrapped(request, *args, **kwargs):
        if not request.user.is_authenticated:
            raise PermissionError
        return f(request, *args, **kwargs)
    return wrapped

@require_auth
def view_dashboard(request):
    return render(...)

Web frameworks use the pattern extensively for access control.

Result transformation

def stringify(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
        return str(f(*args, **kwargs))
    return wrapped

@stringify
def double(n):
    return n * 2

double(5)        # "10"

The pattern admits modifying the function’s output.

Typing decorators

For decorators that preserve the wrapped function’s signature, type hints use ParamSpec:

from typing import Callable, ParamSpec, TypeVar
from functools import wraps

P = ParamSpec("P")
R = TypeVar("R")

def with_logging(f: Callable[P, R]) -> Callable[P, R]:
    @wraps(f)
    def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {f.__name__}")
        return f(*args, **kwargs)
    return wrapped

The ParamSpec admits the type checker to preserve the signature; without it, the decorator’s return type would lose information about the wrapped function’s parameters.

For decorator factories:

def repeat(n: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(f: Callable[P, R]) -> Callable[P, R]:
        @wraps(f)
        def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
            for _ in range(n):
                result = f(*args, **kwargs)
            return result
        return wrapped
    return decorator

The treatment of ParamSpec and decorator typing is in Type hints.

A note on the conventional discipline

The contemporary Python decorator advice:

  • Use @wraps in every decorator that wraps a function.
  • Use functools.lru_cache and cache for memoisation.
  • Use class decorators for cross-cutting concerns on classes.
  • Use decorator factories for parameterised decorators.
  • Use ParamSpec for type-correct decorators.

Decorators are a powerful mechanism but easy to overuse. The conventional discipline is to use them for cross-cutting concerns (logging, timing, caching, registration) — not for the function’s principal logic. A function whose meaning is buried in three layers of decorator wrapping is harder to read than one with the same logic written explicitly.