codejump

AcademyPython

Decorators in Python

What @decorator actually rewrites, why forgetting functools.wraps breaks introspection, and how a decorator that takes arguments needs a third level of nesting.

3 min readUpdated

3 exercises

Waiting at the end of this page.

What @ actually does

@decorator above a function definition is not special syntax with its own rules — it is one line of ordinary code, rewritten:

python
@decorator
def greet():
    return 'hi'
python
def greet():
    return 'hi'
greet = decorator(greet)

decorator receives the function object, and whatever it returns is bound to the name greet afterward. That returned value does not have to be a function at all — it can be a class instance, a different function entirely, or the original function unchanged. The only contract is "takes a callable, returns something."

The shape almost every decorator has

python
def logged(fn):
    def wrapper(*args, **kwargs):
        print(f'calling {fn.__name__}')
        return fn(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    return a + b

wrapper closes over fn — the same closure mechanic that makes a nested function remember a variable from its enclosing scope after that scope has returned. Every call to add is now really a call to wrapper, which does its own work and then delegates. The args and kwargs collector in wrapper's signature is what lets one decorator wrap a function of any arity, rather than one written for exactly two positional parameters.

The bug every undecorated decorator has

python
add.__name__   # 'wrapper' — not 'add'
add.__doc__    # None — the original docstring is gone

Once add is reassigned to wrapper, every introspection tool — debuggers, help(), API documentation generators, pickle — sees wrapper's identity, not add's. The fix is one line, and it is the reason every production decorator has it:

python
from functools import wraps

def logged(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f'calling {fn.__name__}')
        return fn(*args, **kwargs)
    return wrapper

wraps(fn) copies __name__, __doc__ and a few other attributes from fn onto wrapper, so the wrapped function still identifies itself correctly from the outside.

A decorator that takes its own arguments

@logged takes no parentheses because logged itself is the decorator. A decorator written as @retry(times=3) needs one more level of nesting, because retry(times=3) has to run first and hand back the actual decorator:

python
def retry(times):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
        return wrapper
    return decorator

@retry(times=3)
def fetch():
    ...

Three functions, three jobs: retry captures the configuration, decorator captures the function being wrapped, wrapper runs on every call. Losing track of which level owns which variable is the most common bug in a parametrised decorator — a fix belongs in wrapper, a setting belongs in retry.

Stacking more than one

python
@logged
@retry(times=3)
def fetch():
    ...

Decorators apply bottom to top: fetch is wrapped by retry first, and the result of that is wrapped by logged second. Calling fetch therefore runs logged's wrapper on the outside, which calls retry's wrapper, which calls the original. Reversing the order changes what happens on a failed attempt — with logged innermost, a retried call would log once per attempt instead of once overall.

Classes can decorate too

Anything callable works as a decorator, including a class whose __call__ method does the wrapping — useful when a decorator needs to keep state across calls, like a counter of how many times a function ran, without the extra closure variable a function-based version would need.

Now practice it

1 challenge · 2 quizzes

Reading this page is the cheap half. These are the exercises that make you use it.