A small shop's orders module has four functions, and each one starts with the same two lines: note the time, and at the end, print how long the work took. The timing lines are not the work. They are scaffolding around the work, copied into every function by hand. When the team decides to log the timings to a file instead of printing them, someone has to edit all four functions. When one of them is missed, the timings quietly disagree, and nobody notices until a slow afternoon gets blamed on the wrong function. That is the problem a decorator solves: behaviour that wraps a function, written once and applied wherever it is needed, without touching the function's own code.
A decorator is a function that takes a function and returns a new one, usually one that calls the original and adds something around it. Writing @timed above a function is the same as writing total = timed(total) after defining it, only shorter and harder to forget. The wrapper is what actually runs when the function is called, so it can time the call, log the arguments, check a permission, or cache the result, and the wrapped function never knows any of it happened. Every cross cutting concern, timing, logging, retrying, validating, gets one home instead of a copy in every function.
The most common decorators are the ones that come with Python itself. @property turns a method into an attribute, @staticmethod and @classmethod change how a method receives its arguments, and @functools.lru_cache remembers results so an expensive function runs once per input. Frameworks lean on them harder: a web framework marks a function as a route with a decorator, and a test framework marks a function as a test the same way.
What I would check before using it
My judgement, from years of reading other people's Python, is this: reach for a decorator when the same behaviour wraps several functions and does not belong inside any of them. Timing, logging, authentication checks, retries, and caching are the honest cases. Each one is the same code in every function if you write it by hand, and each one is a single decorator if you write it once. The test I use is simple. If I delete the decorator, the function should still do its own job, only without the extra behaviour. If deleting it breaks the function's real work, the decorator is holding logic that belongs inside the function, and that is the wrong split.
I would not write a decorator for a behaviour used exactly once. A single timing block inside one function is clearer than a decorator defined for one call site. I would also be careful with decorators that change what a function returns or swallow its errors, because the caller cannot see that from the call site. A decorator that returns a default value when the wrapped function raises an error looks convenient, and it hides failures that the caller needed to know about. The same caution applies to decorators with hidden state, like a cache that never clears: the function looks pure and the behaviour says otherwise.
The verdict, in short: use decorators for repeated wrapping behaviour, keep them small and honest about what they add, and use functools.wraps every time, for the reason the worked example shows. A decorator that changes a function's name or docstring is a bug waiting for the first person who reads an error message or a help page.
A worked example that is not a toy
The worked example is the timing problem from the opening: a small orders module with a function that totals paid orders, timed by a decorator instead of by hand. The orders are four real rows from a shop's day, and the function adds up the paid ones.
import time from functools import wraps ORDERS = [ {"id": 201, "paid": True, "amount": 120.50}, {"id": 202, "paid": False, "amount": 45.00}, {"id": 203, "paid": True, "amount": 210.00}, {"id": 204, "paid": True, "amount": 101.50}, ] def timed_no_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_no_wraps def total_paid_orders(orders): """Add up the amounts of the paid orders.""" return sum(o["amount"] for o in orders if o["paid"]) It works. Calling it prints the timing and returns 432.0, which is 120.50 plus 210.00 plus 101.50. But something is wrong, and it shows up the first time anyone inspects the function:
print("name:", total_paid_orders.__name__) print("docstring:", total_paid_orders.__doc__) print("total:", total_paid_orders(ORDERS)) Output:
name: wrapper docstring: None total_paid_orders took 0.0000s total: 432.0 The function's name is now wrapper and its docstring is gone, because the decorator returned its own inner function and never copied the original's identity across. Error messages will name wrapper, documentation tools will show nothing, and a test that checks the docstring will fail for a reason that looks unrelated. The fix is one line, @wraps(func) from functools, placed on the wrapper inside the decorator:
def timed(func): @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 total_paid_orders_fixed(orders): """Add up the amounts of the paid orders.""" return sum(o["amount"] for o in orders if o["paid"]) Output:
name: total_paid_orders_fixed docstring: Add up the amounts of the paid orders. total_paid_orders_fixed took 0.0000s total: 432.0 The name and docstring survive, and the timing still prints. The timing itself is 0.0000 seconds because four rows take no measurable time, so here is the same decorator on 200,000 generated orders, where the work is big enough to measure:
total_paid_orders_big took 0.0158s total: 6466279.5 The exact seconds change a little on every run, the second run here took 0.0117 seconds, but the total is the same every time, 6466279.5. That is the point of the decorator: the timing code was written once, and it now measures any function it wraps, while the functions themselves stay short and readable.
Common mistakes and how to fix them
1. Forgetting @wraps, so the wrapped function loses its name. As shown above, __name__ becomes wrapper and __doc__ becomes None. The fix is @wraps(func) on the inner function, in every decorator you write.
2. Forgetting to return the result from the wrapper. A wrapper that calls func(*args, **kwargs) but never returns its value makes every decorated function return None. When this was run, add(2, 3) printed None instead of 5, with no error at all. The fix is one word: return func(*args, **kwargs).
3. Writing a wrapper that takes no arguments. A wrapper defined as def wrapper(): cannot wrap a function that takes arguments. When this was run, calling the decorated function raised TypeError: wrapper() takes 0 positional arguments but 1 was given. The fix is def wrapper(*args, **kwargs): and passing both through to the wrapped function.
4. Calling a plain decorator as if it took arguments. Writing @plain() when plain is written to take the function directly raises TypeError: plain() missing 1 required positional argument: 'func', because the empty call runs before the function exists. The fix is either @plain with no brackets, or rewriting the decorator as a factory that takes arguments and returns the real decorator.
Where this leaves you
Decorators are worth learning because they are the standard way Python handles behaviour that belongs around a function rather than inside it. Timing, logging, and caching written once beat the same lines copied into every function, and the built in decorators in the standard library and in frameworks are the same idea at larger scale. The habits that matter are small: always use functools.wraps, always return the result, and always accept *args and **kwargs. The next step is to take a module of your own, find a block of code that appears at the top of three functions, and move it into a decorator. When the functions get shorter and nothing about their results changes, the decorator has earned its place.
Further reading
- Python Glossary, decorator
- Python Documentation, functools.wraps
- PEP 318, Decorators for Functions and Methods
Originally published at https://practicaldevblog.blogspot.com/2026/11/python-decorators-explained-small.html

