What a decorator actually does to your function

You want to time every function that calls a slow external API. You can paste time.perf_counter() calls into each one, or write the measurement once and apply it everywhere. A decorator is the second option. It is a function that takes another function, wraps it in extra behavior, and returns the wrapped version under the original name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@timed
def fetch_price(ticker):
    time.sleep(0.2)  # stand-in for a network call
    return 142.50

fetch_price("AAPL")  # fetch_price took 0.201s

@timed above def fetch_price is shorthand for fetch_price = timed(fetch_price). Python calls timed once, right after fetch_price is defined, and rebinds the name fetch_price to whatever timed returns. That’s all the @ does.

How decorators work: functions as objects and closures

Python functions are objects. You can pass one as an argument and return one from another function, the same way you would a list. wrapper is also a closure: it captures func from the enclosing scope and keeps using it every time it runs, long after timed has returned.

That’s the whole mechanism. Decorators with arguments, class-based decorators and built-ins like @property are all variations on it.

How functools.wraps keeps the function’s name and docstring

The timed example has a bug you won’t see until you inspect the result:

1
2
print(fetch_price.__name__)  # wrapper
print(fetch_price.__doc__)   # None

fetch_price lost its name and docstring, because wrapper is what got returned. Anything that reads function metadata sees wrapper instead: help(), debuggers, web frameworks that derive route or endpoint names from the function. Fix it with functools.wraps:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from functools import wraps

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

wraps copies __name__, __doc__, __module__ and __qualname__ (plus __annotations__) from the original onto the wrapper, and adds a __wrapped__ attribute pointing back to it. It costs one line. Put @wraps(func) on every wrapper you write.

How to write a decorator with arguments

A plain decorator takes one argument, the function. Once the decorator needs its own configuration, like how many times to retry and how long to wait, you add a layer: a function that takes the configuration and returns a decorator.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import time
from functools import wraps

def retry(times=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    last_error = e
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(times=5, delay=2)
def fetch_price(ticker):
    ...

Read it from the outside in. retry(times=5, delay=2) runs first and returns decorator. decorator(fetch_price) runs next and returns wrapper. wrapper is the thing that executes on every call to fetch_price(...).

Three levels of nesting is harder to read than one. If times and delay never change across your codebase, hardcode them and go back to the single-argument form.

How to write a class-based decorator

Any object with a __call__ method can act as a decorator. Reach for a class when the decorator has to hold state across calls, not only configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class CountCalls:
    def __init__(self, func):
        self.func = func
        self.calls = 0

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

@CountCalls
def process_order(order_id):
    ...

process_order(101)
process_order(102)
print(process_order.calls)  # 2

@CountCalls replaces process_order with an instance of CountCalls. That instance keeps calls between invocations. A closure with a nonlocal counter could do the same, but the class makes the state explicit and gives you somewhere to put a reset() method.

Two catches. wraps doesn’t apply here directly, so call functools.update_wrapper(self, func) in __init__ if you want the metadata. And this version breaks on methods: the instance has no __get__, so self of the decorated method never gets passed in. For decorating methods, a function-based decorator is the simpler choice.

Built-in decorators: @staticmethod, @property, and @lru_cache

The decorators Python ships with use the same mechanism, so they stop looking opaque once you’ve written one yourself:

  • @staticmethod and @classmethod change what a method receives as its first argument: nothing extra, or the class instead of the instance.
  • @property turns a method call into attribute access, obj.value instead of obj.value(), while still running code underneath.
  • @functools.lru_cache(maxsize=128) returns a cached result when a function is called again with the same arguments. @functools.cache (Python 3.9+) does the same with no size limit. Use cache when the set of possible inputs is small and bounded, lru_cache when it isn’t.
1
2
3
4
5
6
7
from functools import lru_cache

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

Without the cache, fibonacci(35) recomputes the same sub-results millions of times. With it, each n is computed once.

In what order do stacked decorators run?

Decorators apply bottom-up and run top-down:

1
2
3
4
@retry(times=3)
@timed
def fetch_price(ticker):
    ...

timed wraps fetch_price first, and retry wraps the result. At call time, retry’s wrapper runs first and calls the timed version on each attempt. So timed prints one number per attempt and never counts the sleep between retries. Swap the two lines and timed sits outside retry, reporting the whole retry loop, sleeps included, as a single number.

When is a decorator the wrong tool?

Decorators hide control flow. A function whose body is one line might open a database transaction and run a permission check before that line executes, and nothing at the call site tells you. That trade is worth it for concerns that cut across many functions and should behave identically everywhere: logging, timing, caching, auth. It’s a liability when the behavior belongs to one function only. Write that inline, where the next reader will see it.

Stack traces get harder to read too. Three stacked decorators add three extra frames between the caller and your code. If a decorator swallows exceptions (like retry catching ConnectionError) or changes the return value, say so in the decorated function’s docstring, which wraps keeps visible. Whoever reads the traceback next needs that hint.

When should you extract a decorator in your own code?

The signal is repetition: the same five lines of setup or teardown at the top of a third function. Extract them into a decorator before writing the fourth copy. If you’re experimenting, do it in an isolated environment as described in Python Virtual Environments: venv, pip and uv, and when you add tests for the decorator, test it against a tiny dummy function first: the layering in Unit vs Integration vs End-to-End Tests Explained applies, and a decorator is about as unit-testable as code gets.

One case where you should stop: if what you want to inject is a dependency, such as a database connection or a configured HTTP client, that isn’t a cross-cutting behavior. Pass it in through the constructor, as shown in What Is Dependency Injection (With Examples).