Python

Decorators, from the ground up

What a decorator actually is under the hood, why functions are objects, and how to write one that doesn't quietly break the function it wraps.

Intermediate

4 min read

The one fact that makes decorators make sense

In Python, a function is just an object. It can be assigned to a variable, passed as an argument, and returned from another function — the same as an integer or a string. Once that's true, a decorator stops being magic syntax and becomes something much simpler: a function that takes a function and returns a function.

def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper
 
def greet(name):
    return f"hello, {name}"
 
greet = shout(greet)
greet("ada")  # "HELLO, ADA"

That last line — greet = shout(greet) — is the entire mechanism. The @ syntax is only sugar for it:

@shout
def greet(name):
    return f"hello, {name}"

is exactly equivalent to greet = shout(greet). Nothing else is happening. Once this clicks, every decorator you'll ever read is legible: find the function being passed in, find what gets returned, and you know what actually runs when the decorated function is called.

The bug almost everyone writes the first time

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:.4f}s")
        return result
    return wrapper
 
@timed
def slow_query():
    """Runs the expensive report query."""
    time.sleep(0.2)
 
print(slow_query.__name__)   # 'wrapper'  — wrong
print(slow_query.__doc__)    # None       — wrong

The decorated function's identity got quietly replaced. Anything that introspects the function — debuggers, API doc generators, help(), test frameworks that print function names in failure output — now sees wrapper, not slow_query. This is exactly the kind of bug that doesn't crash anything, so it survives in real codebases for years.

The fix is functools.wraps, applied to the inner function:

import functools
import time
 
def timed(func):
    @functools.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:.4f}s")
        return result
    return wrapper
 
@timed
def slow_query():
    """Runs the expensive report query."""
    time.sleep(0.2)
 
print(slow_query.__name__)  # 'slow_query' — correct
print(slow_query.__doc__)   # 'Runs the expensive report query.' — correct

functools.wraps copies __name__, __doc__, and a few other metadata attributes from the original function onto the wrapper. The rule worth internalizing: any decorator that defines an inner function should decorate that inner function with functools.wraps(func). There's essentially no cost to including it and a real, if quiet, cost to leaving it out.

Decorators that take their own arguments

@timed above takes no arguments — it's applied directly. Something like @retry(times=3) needs an extra layer, because retry(times=3) has to itself return a decorator:

import functools
import time
 
def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
            raise last_error
        return wrapper
    return decorator
 
@retry(times=3)
def flaky_api_call():
    ...

Three nested functions, three jobs:

  • retry(times) — takes the decorator's own arguments, returns the real decorator.
  • decorator(func) — takes the function being decorated, returns the wrapper. This is the part that's a normal decorator, same shape as timed above.
  • wrapper(*args, **kwargs) — the thing that actually runs on every call.

Built-in decorators, briefly

  • @staticmethod — the method receives neither self nor cls. It's a plain function that happens to live in the class's namespace, used when the logic genuinely doesn't need instance or class state.
  • @classmethod — the method receives cls (the class itself) instead of self. The most common real use is an alternative constructor: Point.from_tuple((3, 4)) calling cls(x, y) internally.
  • @property — turns a method into something accessed like an attribute (obj.value instead of obj.value()), letting you add validation or computed logic behind what looks like a plain field.
  • @functools.lru_cache — memoizes a function's return value by its arguments, so repeated calls with the same inputs skip recomputation. Useful for pure functions with expensive, repeatable work; dangerous on functions with side effects or unhashable arguments.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What is `@shout` above `def greet(name):` actually equivalent to?

2. Without functools.wraps, what happens to a decorated function's __name__?

3. Why does a decorator that takes its own arguments, like @retry(times=3), need three levels of nested functions instead of one?

4. What does @property let you do that a plain method doesn't?