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.
@measure_time
def load_customers():
...
load_customers()
# load_customers took 0.42 sThe 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.
# With the at sign
@measure_time
def load():
...
# Without the at sign, strictly equivalent
def load():
...
load = measure_time(load) # replaced by its wrapped versionThe 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.
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.
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 functionHow 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.
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.wraps | With functools.wraps |
|---|---|
load.__name__ is "wrapper" | It really is "load" |
| The original documentation is lost | It is copied onto the wrapper |
| Test reports name everything alike | Every 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.
| Decorator | What it changes |
|---|---|
property | A method is used like an attribute |
staticmethod | The method no longer expects self |
classmethod | It receives the class rather than the instance |
dataclass | It writes the initialisation and the comparison |
functools.lru_cache | It 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
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.
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.
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.