Decorators
You have already used decorators: @property, @classmethod and @dataclass all sit on the line above a function or class and change how it behaves. A decorator is a function that takes another function and returns an improved version of it, without you editing the original code.
Decorators are perfect for jobs that many functions need: logging, timing, checking permissions, retrying or caching results. In this lesson you will build your own, one step at a time.
Functions are values
Decorators rely on two facts you met in earlier lessons. First, a function is an object: you can store it in a variable or pass it to another function. Second, a function can define and return an inner function that remembers its surroundings, which is a closure (see Scope & Closures).
def shout(text):
return text.upper() + "!"
speak = shout # no brackets: the function itself
print(speak("jai hind"))
def apply_twice(func, value):
return func(func(value))
print(apply_twice(shout, "chalo"))JAI HIND! CHALO!!
Writing a decorator by hand
A decorator takes a function, defines a wrapper function that does something extra around the original, and returns the wrapper.
def announce(func):
def wrapper():
print("Before the call")
func()
print("After the call")
return wrapper
def open_shop():
print("Shop is open")
open_shop = announce(open_shop)
open_shop()Before the call Shop is open After the call
The line open_shop = announce(open_shop) replaces the original function with the wrapper. When you call open_shop(), you are really calling wrapper(), which calls the original in the middle.
The @ syntax
Writing the function name three times is clumsy, so Python offers a shortcut. Putting @announce above the definition means exactly the same as open_shop = announce(open_shop) after it.
A real decorator should also work with any function, whatever arguments it takes, and should pass back its return value. Use *args and **kwargs (see Function Arguments) to accept and forward everything:
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args} {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add_gst(price, rate=18):
"""Return the price including GST."""
return round(price * (100 + rate) / 100, 2)
total = add_gst(1299, rate=5)
print(total)
print(add_gst.__name__, "-", add_gst.__doc__)Calling add_gst with (1299,) {'rate': 5}
add_gst returned 1363.95
1363.95
add_gst - Return the price including GST.@functools.wraps(func) copies the original function's name, docstring and other details onto the wrapper. Without it, add_gst.__name__ would say wrapper, which makes error messages and documentation confusing. Always use it.
A practical example: checking arguments
Decorators keep repeated checks out of your main logic. Here one decorator protects several money functions from negative or zero amounts:
import functools
def positive_amount(func):
@functools.wraps(func)
def wrapper(amount, *args, **kwargs):
if amount <= 0:
raise ValueError(f"{func.__name__}: amount must be positive, got {amount}")
return func(amount, *args, **kwargs)
return wrapper
@positive_amount
def deposit(amount):
return f"Deposited ₹{amount}"
@positive_amount
def send_upi(amount, to):
return f"Sent ₹{amount} to {to}"
print(deposit(500))
print(send_upi(250, "meera@okbank"))
try:
send_upi(-40, "rohan@okbank")
except ValueError as error:
print("Error:", error)Deposited ₹500 Sent ₹250 to meera@okbank Error: send_upi: amount must be positive, got -40
Decorators with arguments
Sometimes you want to configure the decorator, as in @retry(times=3). That needs one more level: retry(times=3) is called first and must return the actual decorator. So you write a function that returns a decorator that returns a wrapper.
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except ConnectionError as error:
print(f"Attempt {attempt} failed: {error}")
raise ConnectionError(f"Gave up after {times} attempts")
return wrapper
return decorator
replies = iter(["timeout", "timeout", "OK"])
@retry(times=3)
def fetch_result():
reply = next(replies)
if reply != "OK":
raise ConnectionError(reply)
return "Result: Pass, 78%"
print(fetch_result())Attempt 1 failed: timeout Attempt 2 failed: timeout Result: Pass, 78%
The fake replies iterator makes the first two calls fail, the way a busy exam-results server might, and the third succeeds. The decorator hides all the retry logic, so fetch_result() stays short and clear.
Built-in decorators: caching with functools.cache
The standard library includes many ready-made decorators. @functools.cache remembers the result for each set of arguments, so repeated calls with the same arguments return instantly instead of recalculating. This is called memoisation.
import functools
calls = 0
@functools.cache
def ways_to_climb(steps):
"""Number of ways to climb stairs taking 1 or 2 steps at a time."""
global calls
calls += 1
if steps <= 1:
return 1
return ways_to_climb(steps - 1) + ways_to_climb(steps - 2)
print(ways_to_climb(40))
print("Function body ran", calls, "times")
print(ways_to_climb.cache_info())165580141 Function body ran 41 times CacheInfo(hits=38, misses=41, maxsize=None, currsize=41)
Without the cache, this recursive function would run its body hundreds of millions of times for 40 steps. With it, each step count is calculated once. Use @functools.lru_cache(maxsize=256) when you want to limit how many results are stored. Only cache functions whose result depends purely on their arguments.
Common mistakes
- Forgetting to return the wrapper. If the decorator has no
return wrapper, the decorated name becomesNone, and calling it raisesTypeError: 'NoneType' object is not callable. - Forgetting to return the result. A wrapper that calls
func(*args, **kwargs)withoutreturnmakes every decorated function returnNone. - Leaving out
functools.wraps. The function loses its name and docstring, which confuses debugging and help tools. - Writing
@retryinstead of@retry(times=3). A decorator that takes arguments must be called, with brackets.
Key points
- A decorator takes a function and returns a new function, usually a wrapper that adds behaviour around the original.
@decoratorabove adefmeansfunc = decorator(func).- Wrappers should accept
*args, **kwargs, pass them on, and return the original result. - Always decorate the wrapper with
@functools.wraps(func). - Decorators with arguments need three levels: a factory, the decorator and the wrapper.
- The standard library includes
@functools.cache,@functools.lru_cache,@property,@classmethodand@dataclass.
Exercise
Write a decorator count_calls that counts how many times a function has been called and stores the count in an attribute wrapper.calls. Use functools.wraps. Decorate a function book_seat(name) that returns "Seat booked for <name>", call it for Aarav, Priya and Kabir, and then print the count and the function's __name__.
Show solution
import functools
def count_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.calls += 1
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
@count_calls
def book_seat(name):
return f"Seat booked for {name}"
for person in ["Aarav", "Priya", "Kabir"]:
print(book_seat(person))
print(book_seat.calls, book_seat.__name__)Seat booked for Aarav Seat booked for Priya Seat booked for Kabir 3 book_seat
Functions are objects, so the wrapper can carry its own attribute, calls. Thanks to functools.wraps, the decorated function still reports its real name.
Quick Quiz
1. What does @timer above def report(): mean?
The @ syntax passes the function to the decorator and stores the result under the same name.
2. Why do wrappers use *args, **kwargs?
A general-purpose wrapper must work with functions of any signature.
3. What does functools.wraps(func) do?
Without it, the decorated function would appear to be named wrapper.
4. What does @functools.cache do?
Cached calls with the same arguments return the stored result instead of running the body again.
Learn Python with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
