Decorators in Python: changing a function without rewriting it

A decorator wraps a function to add behaviour without touching its code. What the at sign really replaces, and how to write your own.
6 min read
Believemy logo

Definition

Ten functions load data, and you want to know how long each one takes. The obvious answer is to paste a stopwatch at the start and at the end of all ten, then go through them again the day the display changes.

A decorator exists for that precise annoyance. It is a function that takes another one, wraps it in extra behaviour and hands back the wrapped version. The stopwatch is then written once, and applies without touching the existing code.

It all fits in an at sign placed right above the def. Here is that stopwatch once tidied away into a decorator called measure_time.

PYTHON
@measure_time
def load_customers():
    ...

load_customers()
# load_customers took 0.42 s

The decorated function keeps its name and is called as before: nothing changes for the code already using it. Putting the same stopwatch on an eleventh function takes one line.


What the at sign replaces

The at sign looks like a magic construct of the language, and that feeling blocks people as soon as something resists. It is only a shorthand, though, which Python replaces with a very ordinary call: the two blocks below do rigorously the same thing.

PYTHON
# With the at sign
@measure_time
def load():
    ...

# Without the at sign, strictly equivalent
def load():
    ...
load = measure_time(load)   # replaced by its wrapped version

The last line says it all: measure_time receives load, builds a wrapped version of it and stores that version under the same name. In Python a function is an object like any other, which can be passed as an argument and handed back with a return.

Warning

On a decorator that expects no setting, writing @measure_time() with brackets calls it without giving it any function to wrap. Python raises a TypeError that talks about a missing argument and never about the at sign, so the fault gets looked for in the wrong place.


Writing your own

The skeleton has three floors: an outer function that receives the function to decorate, an inner function that calls it while adding something around it, and the return of that inner function.

PYTHON
import functools
import time

def measure_time(function):              # function: the one being decorated
    @functools.wraps(function)           # copies its name and its documentation
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = function(*args, **kwargs)     # the original call, untouched
        elapsed = time.perf_counter() - start
        print(f"{function.__name__} took {elapsed:.2f} s")
        return result
    return wrapper                       # it takes the place of the function
The call goes through the decorator before and after the function The call reaches the wrapper built by the decorator, never the function itself. The wrapper runs what comes first, then calls the original function left untouched, then runs what comes after before handing the result back. load_customers() the call looks unchanged @measure_time the returned wrapper 1. Before the call the start time is recorded def load_customers() 2. The original code, untouched 3. After the call the elapsed time is printed the result, plus the timing

How can the wrapper still talk about function when measure_time has finished running? Because it is a closure: it carries with it the variables of the place where it was born.

The args and kwargs are not decoration: without them the wrapper would accept no parameter and would break the first function expecting one. Forgetting the return costs just as much, since the wrapper then hands back None and the breakage shows up elsewhere.

Good to know

The at sign acts once only, when Python reads the definition, which means at import time. What runs on every call is the wrapper. A decorator that reads a configuration along the way therefore does it at startup, even if the decorated function is never called.


The lost identity trap

What comes back is the wrapper: the rest of the program no longer sees the original function, it sees its stand-in. Without functools.wraps, the __name__ of the decorated function becomes "wrapper", its docstring vanishes and the built-in help says nothing useful any more.

The table compares what the rest of the program sees in the two cases.

Without functools.wrapsWith functools.wraps
load.__name__ is "wrapper"It really is "load"
The original documentation is lostIt is copied onto the wrapper
Test reports name everything alikeEvery function keeps its name

The symptom shows up later, and rarely where it is looked for: a framework registering its routes by function name refuses the second one, since every decorated function now carries the same name. The error message, for its part, talks about a duplicate route.


The ones used without writing them

Before writing your own, look at the ones that already exist: most decorators met day to day come from the standard library or from a framework such as Flask or pytest.

Here are the five met first, with what each one changes where it appears.

DecoratorWhat it changes
propertyA method is used like an attribute
staticmethodThe method no longer expects self
classmethodIt receives the class rather than the instance
dataclassIt writes the initialisation and the comparison
functools.lru_cacheIt keeps already computed results in memory

A homemade decorator earns its place when the same precaution repeats across ten different functions. Below that, a plain function called by hand stays more readable, and is debugged without untangling two levels of call.


Frequently asked questions

Question

Can several decorators be stacked on one function?

Yes, and the order genuinely matters. They apply from the bottom up: the one written right above the def wraps the function first, the next one wraps that result. Swapping two lines can therefore put an access check behind a cache, and serve everyone an answer computed for a single person.

Question

How can a parameter be passed to a decorator?

By adding one floor: a function that receives the parameter and returns the decorator, which in turn returns the wrapper. That floor is what explains the writing @repeat(3), where the bracket really calls repeat to build the decorator. Brackets when there is a setting to pass, none otherwise.

Question

Does a decorator slow the program down?

It adds one function call, whose cost is measured in fractions of a microsecond and stays invisible outside very tight loops. The real risk lies elsewhere: a decorator that swallows an error or alters a result makes debugging far harder, because the observed behaviour no longer matches the code being read. Our Python course introduces them once functions are mastered.

Related terms

Discover our python glossary

Browse the terms and definitions most commonly used in development with Python.

Share this article

Want to help us? Share this article on your networks or even better: on your site, in an article or in your newsletter.