Home Tutorials Functions

Functions

Decorators Explained: Wrap Functions Without Touching Them

Pyford Notes July 1, 2026 8 min read
Key points
  • Functions are first-class objects in Python; they can be stored in variables and passed as arguments.
  • A decorator is a callable that takes a function, wraps it, and returns the wrapper.
  • The @decorator line is syntactic sugar for func = decorator(func).
  • Use functools.wraps to preserve the original function's name and docstring.

Functions are first-class objects

Before decorators make sense, you need to be comfortable with one Python fact: functions are objects just like integers or strings. You can assign them to variables, store them in lists, and pass them to other functions as arguments.

def greet(name):
    return f"Hello, {name}"

say_hello = greet          # no call, just a reference
print(say_hello("world"))  # "Hello, world"

def apply(func, value):
    return func(value)

print(apply(greet, "Alice"))  # "Hello, Alice"

This is the foundation. A decorator is just a function that accepts another function and returns something (usually a modified function).

Writing a wrapper from scratch

Say you want to log every call to a function without editing it. You write a wrapper that runs before and after the original call:

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} ...")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

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

add = log_calls(add)   # replace add with the wrapped version
add(2, 3)
# Calling add ...
# add returned 5

The key move is that wrapper closes over the func argument from the outer scope. Python closures let inner functions remember variables from their enclosing function, so func stays alive as long as wrapper does.

The @ shorthand

The pattern add = log_calls(add) is so common that Python has dedicated syntax for it. Placing @log_calls immediately before a function definition does the same thing:

@log_calls
def multiply(a, b):
    return a * b

multiply(4, 5)
# Calling multiply ...
# multiply returned 20

The @ line is evaluated at import time, not at call time. By the time you call multiply, it is already the wrapped version.

Preserving identity with functools.wraps

There is a subtle problem with the naive wrapper: the wrapped function loses its original __name__ and __doc__, because Python sees wrapper instead of the original. This breaks introspection and documentation tools.

from functools import wraps

def log_calls(func):
    @wraps(func)           # copies name, docstring, etc. onto wrapper
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} ...")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

After adding @wraps(func), calling help(multiply) or checking multiply.__name__ gives the original name and docstring. Always include @wraps when writing decorators intended for production use.

Decorators that accept arguments

Sometimes you want to configure the decorator itself, for example to set how many times a function should retry on failure. You need one extra layer of nesting:

from functools import wraps
import time

def retry(times=3, delay=0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    if attempt == times:
                        raise
                    print(f"Attempt {attempt} failed: {exc}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=4, delay=1.0)
def fetch_data(url):
    ...  # network call that might fail

The call @retry(times=4, delay=1.0) first invokes retry with those arguments, which returns decorator. Then decorator is applied to fetch_data. The three-layer structure—factory, decorator, wrapper—is the standard form for parametrised decorators.

Real-world patterns

Python's standard library and ecosystem use decorators everywhere. A few you will encounter repeatedly:

  • @property — turns a method into an attribute accessor without needing explicit getter/setter plumbing.
  • @staticmethod and @classmethod — alter how a method receives its first argument inside a class.
  • @functools.cache — memoises a function's return values by its arguments, eliminating redundant computation in recursive or repeated calls.
  • @dataclasses.dataclass — generates __init__, __repr__, and __eq__ from annotated class fields automatically.
Stacking order When you stack multiple decorators, they apply from bottom to top. The decorator closest to the function definition is applied first. @timer on top of @log_calls means the timer wraps the already-logged version, not the raw function.