Copying the same timing or logging code into several Python functions often creates maintenance problems. When a small shop's orders module needed to track how long each function took to run, the team ended up editing the same lines in different places. If one function missed an update, inconsistent timing data could slip through, making it unclear which part of the system was slow.
What Are Python Decorators?
Python decorators let you add extra behavior to functions without changing their main logic. A decorator is a callable that takes a function as input and returns a replacement function. This keeps your code DRY (Don't Repeat Yourself) and lets you update cross-cutting concerns like timing and logging in one spot.
To apply a decorator, write @decorator_name above your function definition. This syntax, introduced in PEP 318 by Barry Warsaw, Guido van Rossum, and Alyssa Coghlan, is called "pie syntax". For example, @timed above a function works the same as assigning total = timed(total) after defining it, but the decorator syntax is shorter and less likely to be missed. You can stack multiple decorators, and they apply from the innermost outward, with each decorator getting the result of the one below. The decorator expressions run when the function definition is executed, and the decorated callable is used later when the name is called, as described in the Python Language Reference.
The wrapper function created by the decorator runs every time the decorated function is called. This lets it measure execution time, log arguments, check permissions, or cache results, all without the original function knowing. The decorator may return the original function, a wrapper, a descriptor, or another callable, and can change how the function is called without touching its body.
Python's standard library includes several built-in decorators. @property turns a method into a managed attribute with getter and setter methods, @staticmethod prevents automatic instance or class binding, and @classmethod binds the method to the class as its first argument. These use Python’s descriptor and method-binding model, so their effects involve how attributes are retrieved and what callable is executed. The @functools.lru_cache decorator adds result caching (memoization) by storing recent calls and their returned values, letting repeated calls with the same hashable arguments skip recomputation. The cache can be limited with maxsize, and the decorator exposes cache_info(), cache_clear(), and cache_parameters() for inspection and management. Many frameworks use decorators to mark routes in web apps or identify test functions.
When to Use a Decorator
Decorators work best when the same behavior needs to wrap several functions and doesn't belong inside any single one. Timing, logging, authentication, retries, and caching are all valid uses. If you remove the decorator, the function should still do its main job, just without the extra behavior. If deleting the decorator breaks the function's core logic, the decorator is holding code that should be inside the function instead.
It's usually not worth writing a decorator for a behavior used only once. A single timing block inside one function is clearer than making a decorator for a single use. Be careful with decorators that change what a function returns or suppress errors, since these changes aren't visible at the call site and can hide important failures. Decorators with hidden state, like a cache that never clears, can also confuse if the function looks pure but acts differently. The lru_cache decorator, for example, requires that arguments be hashable and that the function has no side effects, since caching functions with side effects can cause incorrect application behavior. The cache is threadsafe so the wrapped function can be used in multiple threads, as documented by the Python Software Foundation.
Timing Functions with a Decorator: A Real Example
Take the timing problem from earlier. Suppose you have a list of orders and want to total the amounts of paid orders, while also measuring how long the calculation takes. Here's a decorator that times a function but doesn't preserve the original function's metadata:
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"])Calling total_paid_orders(ORDERS) prints the timing and returns 432.0. But if you check total_paid_orders.__name__ and total_paid_orders.__doc__, you'll see the function's name is now wrapper and its docstring is missing. This happens because the decorator returns its own inner function without copying the original's metadata. Error messages and documentation tools will reference wrapper instead of the intended function, making debugging and documentation harder.
The fix is to use @wraps(func) from functools on the inner wrapper function. This is a convenience function for calling update_wrapper() as a function decorator when defining a wrapper, and it copies attributes such as __module__, __name__, __qualname__, __doc__, and __annotations__, and adds a __wrapped__ reference to the wrapped function. This keeps the original function's name and docstring:
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"])Now, total_paid_orders_fixed.__name__ and total_paid_orders_fixed.__doc__ stay correct, and the timing output still appears. With a larger dataset, the decorator measures real execution time, showing how a single decorator can handle timing for any function it wraps. The functools.wraps tool does not automatically make a decorator transparent; the wrapper must still correctly forward positional arguments, keyword arguments, return values, exceptions, and other intended behavior.
Common Decorator Mistakes and How to Avoid Them
- Omitting @wraps causes the wrapped function to lose its name and docstring. Always use @wraps(func) on the inner function to keep important metadata, as recommended by the Python Software Foundation.
- Forgetting to return the result from the wrapper makes decorated functions return None. Always return the result of func(*args, **kwargs).
- Defining a wrapper that takes no arguments prevents it from wrapping functions with parameters. Use def wrapper(*args, **kwargs): to accept any arguments.
- Calling a plain decorator as if it takes arguments (e.g., @plain() when plain expects a function) raises a TypeError. Use @plain with no parentheses unless the decorator is designed as a factory.
Best Practices for Using Decorators
Decorators work best when they handle repeated wrapping behavior, stay small, and clearly state what they add. Always use functools.wraps to keep function metadata, return the result of the wrapped function, and accept *args and **kwargs in the wrapper. These habits help keep your codebase maintainable and your functions easy to understand.
Python’s standard library treats decorators as a core extensibility mechanism across caching, generic dispatch, context management, abstract base classes, and method binding. The functools module includes tools such as singledispatch, singledispatchmethod, total_ordering, cached_property, cache, lru_cache, wraps, and partialmethod, showing that decorators support both behavioral wrapping and registration or object-construction patterns. Decorator design should consider introspection, descriptors, concurrency, cache invalidation, and exception propagation. Some conveniences, such as total_ordering, can increase stack-trace complexity and may be slower than explicitly implementing all comparison methods, as noted in the official documentation.
If you want to see another example of simplifying repetitive programming tasks, check out this earlier breakdown on batch-converting documents to Markdown.
Further Reading
For more on decorators, see the Python Glossary, the functools.wraps documentation, and PEP 318 for the official proposal.
