The Gift-Wrapping Counter
During the Diwali season, many shops in a mall have a gift-wrapping counter. You buy a watch, a book or a box of sweets, and the person at the counter adds coloured paper, a ribbon and a small card. The gift inside does not change at all. It is the same watch. But now it has something extra around it, and the same counter can wrap any gift you bring.
A decorator in Python does exactly this for functions. It takes a function, wraps extra behaviour around it, and gives you back a function that still does the original job, plus something more: printing a log, measuring time, checking a login, or trying again after a failure. You write that extra behaviour once and use it on as many functions as you like.
You will meet decorators everywhere in real Python code: in web frameworks like Flask and Django, in testing tools, and in classes. Once you understand how they work, that code stops looking like magic.
Step 1: Functions Are Objects
Decorators depend on one idea: in Python, a function is an object, just like a number or a string. That means you can store it in a variable, pass it to another function, and create and return it from inside another function. Notice the difference between pack_watch (the function itself) and pack_watch() (calling it).
def pack_watch():
print("Packing a wrist watch")
# 1. A function can be stored in a variable (no brackets, so it is not called)
task = pack_watch
task() # Packing a wrist watch
# 2. A function can be passed to another function
def do_twice(func):
func()
func()
do_twice(pack_watch) # Packing a wrist watch (two times)
# 3. A function can be created inside another function and returned
def make_greeting(shop_name):
def greet(customer):
# greet remembers shop_name: this is called a closure
print(f"Welcome to {shop_name}, {customer}!")
return greet
greet_customer = make_greeting("Lakshmi Gift Corner")
greet_customer("Anita") # Welcome to Lakshmi Gift Corner, Anita!
The last part builds on what you learned about scope in the More on Functions lesson. The inner function greet uses shop_name from the outer function, and it still remembers that value after make_greeting has finished. A function that remembers values from the place where it was created is called a closure. Every decorator you write will use a closure.
Step 2: Your First Decorator
A decorator is simply a function that takes a function and returns a new function. Here, gift_wrap receives func, builds an inner function called wrapper that prints something, calls func, and prints again. Then it returns wrapper.
def gift_wrap(func):
def wrapper():
print("Adding wrapping paper...")
func() # the original gift, unchanged
print("Tying the ribbon. Ready!")
return wrapper
def pack_book():
print("Packing a story book")
pack_book = gift_wrap(pack_book) # replace it with the wrapped version
pack_book()
# Adding wrapping paper...
# Packing a story book
# Tying the ribbon. Ready!
The line pack_book = gift_wrap(pack_book) is the key. The name pack_book now points to wrapper. Because wrapper is a closure, it remembers the original pack_book as func and calls it in the middle.
Step 3: The @ Syntax and Passing Arguments Through
Writing name = decorator(name) after every function is tiring, so Python gives a shortcut. Put @gift_wrap on the line just above def, and Python does the replacement for you. The two forms are exactly the same.
Our first wrapper took no arguments, so it could only wrap functions with no arguments. A useful decorator must work for any function. The fix is *args and **kwargs, which you met in the More on Functions lesson: wrapper collects whatever positional and keyword arguments it receives and passes all of them on to func. It must also return the result of func, or the caller gets None.
def gift_wrap(func):
def wrapper(*args, **kwargs):
print("Adding wrapping paper...")
result = func(*args, **kwargs) # pass every argument through
print("Tying the ribbon. Ready!")
return result # give back the original result
return wrapper
@gift_wrap
def pack_gift(item, quantity=1):
print(f"Packing {quantity} x {item}")
return quantity * 50 # wrapping charge in rupees
charge = pack_gift("photo frame", quantity=2)
print("Wrapping charge: Rs.", charge)
# Adding wrapping paper...
# Packing 2 x photo frame
# Tying the ribbon. Ready!
# Wrapping charge: Rs. 100
This is the pattern you will use almost every time: accept *args, **kwargs, do something before, call func(*args, **kwargs), do something after, return the result.
Step 4: Keep the Function’s Name with functools.wraps
There is one small problem. After decoration, the function’s name and docstring belong to wrapper, not to your function. This confuses error messages, help text and testing tools. The standard library has a fix: put @functools.wraps(func) above your wrapper. It copies the name, the docstring and other details from the original function onto the wrapper.
import functools
def plain_wrap(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def careful_wrap(func):
@functools.wraps(func) # copy the name and docstring
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain_wrap
def pack_toy():
"""Pack a soft toy in a box."""
@careful_wrap
def pack_pen():
"""Pack a pen in a gift case."""
print(pack_toy.__name__, "|", pack_toy.__doc__) # wrapper | None
print(pack_pen.__name__, "|", pack_pen.__doc__) # pack_pen | Pack a pen in a gift case.
Make it a habit. Use @functools.wraps(func) in every decorator you write. It costs one line and saves you confusing bugs later.
Step 5: Practical Decorators
Timing a function
Which version of a function is faster? A timing decorator answers this without changing the function. time.perf_counter() is a clock made for measuring short durations. You read it before and after the call and subtract.
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
seconds = time.perf_counter() - start
print(f"{func.__name__} took {seconds:.4f} seconds")
return result
return wrapper
@timer
def total_sales(bills):
return sum(bills)
@timer
def slow_total(bills):
total = 0
for bill in bills:
total = total + bill
return total
bills = list(range(1, 2_000_001))
print(total_sales(bills)) # total_sales took ... seconds, then 2000001000000
print(slow_total(bills)) # slow_total took ... seconds (usually longer), then 2000001000000
Your times will be different from anyone else’s, because they depend on the computer. The built-in sum() is usually much faster than a hand-written loop.
Decorators with their own arguments: retry
Sometimes the decorator itself needs a setting, for example “try 3 times”. Then you need one more layer: a function that takes the setting and returns a decorator. This outer function is often called a decorator factory. When Python sees @retry(times=3), it first calls retry(times=3), gets a decorator back, and then applies that decorator to your function.
Here, sending a delivery SMS fails twice because of a pretend network problem, and the decorator quietly tries again:
import functools
def retry(times):
"""A decorator factory: retry(3) builds a decorator that tries 3 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
calls = {"count": 0}
@retry(times=3)
def send_delivery_sms(order_id):
calls["count"] += 1
if calls["count"] < 3: # pretend the network fails twice
raise ConnectionError("network busy")
return f"SMS sent for order {order_id}"
print(send_delivery_sms(4521))
# Attempt 1 failed: network busy
# Attempt 2 failed: network busy
# SMS sent for order 4521
Three levels of functions can look confusing at first. Read it from the outside in: retry holds the setting, decorator receives the function, and wrapper runs every time the function is called. In real code you would also wait a little between attempts with time.sleep().
Built-in Decorators You Will Meet
Python already includes some decorators for classes. You saw classes in the Classes and Objects lesson. These three are the most common:
| Decorator | What it does |
|---|---|
@property | Lets you read a method like an attribute: order.total instead of order.total(). Good for values that are calculated from other attributes. |
@staticmethod | A method that needs neither self nor the class. It lives inside the class only because it belongs there logically. |
@classmethod | Receives the class as cls instead of an object. Often used for other ways to create objects, such as from text. |
class GiftOrder:
gst_rate = 0.18
orders_today = 0
def __init__(self, item, price):
self.item = item
self.price = price
GiftOrder.orders_today += 1
@property
def total(self):
"""Used like an attribute: order.total, with no brackets."""
return round(self.price * (1 + self.gst_rate), 2)
@staticmethod
def is_valid_message(message):
"""A helper that needs neither self nor the class."""
return 0 < len(message) <= 50
@classmethod
def from_text(cls, text):
"""Another way to create an order, from text like 'Mug,250'."""
item, price = text.split(",")
return cls(item, float(price))
order = GiftOrder.from_text("Coffee mug,250")
print(order.item, order.total) # Coffee mug 295.0
print(GiftOrder.is_valid_message("Happy Diwali!")) # True
print(GiftOrder.orders_today) # 1
Real-Life Example: The Gift Shop Order System
A gift shop’s billing program has many functions: placing orders, cancelling orders, applying discounts. Every one of them should be logged, and only logged-in staff may use them. Instead of copying the same checks into every function, we write two decorators and stack them. The program uses a simple dictionary called current_user to pretend someone is logged in.
import functools
from datetime import datetime
current_user = {"name": "Kavya", "logged_in": True, "role": "staff"}
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
time_now = datetime.now().strftime("%H:%M")
print(f"[{time_now}] {func.__name__} called with {args}")
result = func(*args, **kwargs)
print(f"[{time_now}] {func.__name__} returned {result!r}")
return result
return wrapper
def login_required(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if not current_user["logged_in"]:
print("Please log in first.")
return None
return func(*args, **kwargs)
return wrapper
@log_call
@login_required
def place_order(item, price, wrap=True):
total = price + (50 if wrap else 0)
return f"{item} ordered by {current_user['name']}, total Rs. {total}"
place_order("Silk scarf", 799)
current_user["logged_in"] = False
place_order("Brass diya", 349, wrap=False)
# [10:30] place_order called with ('Silk scarf', 799)
# [10:30] place_order returned 'Silk scarf ordered by Kavya, total Rs. 849'
# [10:30] place_order called with ('Brass diya', 349)
# Please log in first.
# [10:30] place_order returned None
When you stack decorators, the one closest to the function is applied first. So login_required wraps place_order, and log_call wraps that result. When you call place_order, the outer layer (logging) runs first, then the login check, then your function. Your times in the output will match your own clock.
Try these changes:
- Swap the order of the two decorators and run it again. What does the log show when the user is not logged in?
- Add a
cancel_order(order_id)function with both decorators. - Add the
timerdecorator from Step 5 on top as well.
Common Mistakes
- Forgetting to return the result. If
wrappercallsfuncbut does not return its value, every decorated function returnsNone.
def gift_wrap(func):
def wrapper(*args, **kwargs):
print("Wrapping...")
func(*args, **kwargs) # mistake: the result is thrown away
return wrapper
@gift_wrap
def price_of(item):
return 199
print(price_of("keychain")) # Wrapping... then None, not 199
# Fix: write return func(*args, **kwargs) inside wrapper
- Calling the function instead of passing it. Inside a decorator,
return wrapperis correct.return wrapper()runs the wrapper once, at decoration time, and returns its result instead of a function. - Leaving out
*args, **kwargs. Awrapper()with no parameters fails with aTypeErroras soon as the decorated function is called with an argument. - Mixing up
@retryand@retry(3). A decorator factory must be called with brackets. Writing@retryalone passes your function in astimes, and your function silently stops working the way you expect. - Skipping
functools.wraps. Everything seems to work, but every function is now namedwrapper, which makes errors and logs hard to read.
Practice Task: Two Decorators for the Shop
Write two decorators for the gift shop and use the test code at the bottom of the starter to check them. Your solution should:
- Have a
count_callsdecorator that printscheck_stock call number 1, then2, and so on. - Have an
admin_onlydecorator that printsAdmins only.and returnsNoneunlesscurrent_user["role"]is"admin". - Pass all arguments through with
*args, **kwargsand return the original result. - Use
functools.wrapsin both.
import functools
current_user = {"name": "Kavya", "role": "staff"}
def count_calls(func):
# TODO 1: count how many times func is called and print
# "<name> call number <n>" before calling it
# Hint: store the count on the wrapper, like wrapper.calls = 0
# TODO 3: use functools.wraps
return func # replace this with your wrapper
def admin_only(func):
# TODO 2: run func only if current_user["role"] == "admin",
# otherwise print "Admins only." and return None
# TODO 3: use functools.wraps
return func # replace this with your wrapper
@count_calls
def check_stock(item):
return f"{item}: 12 in stock"
@admin_only
def change_price(item, new_price):
return f"{item} now costs Rs. {new_price}"
print(check_stock("Teddy bear"))
print(check_stock("Photo frame"))
print(change_price("Teddy bear", 499))
current_user["role"] = "admin"
print(change_price("Teddy bear", 499))
Show one possible answer (try it yourself first!)
import functools
current_user = {"name": "Kavya", "role": "staff"}
def count_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.calls += 1
print(f"{func.__name__} call number {wrapper.calls}")
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
def admin_only(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if current_user["role"] != "admin":
print("Admins only.")
return None
return func(*args, **kwargs)
return wrapper
@count_calls
def check_stock(item):
return f"{item}: 12 in stock"
@admin_only
def change_price(item, new_price):
return f"{item} now costs Rs. {new_price}"
print(check_stock("Teddy bear"))
print(check_stock("Photo frame"))
print(change_price("Teddy bear", 499))
current_user["role"] = "admin"
print(change_price("Teddy bear", 499))
# check_stock call number 1
# Teddy bear: 12 in stock
# check_stock call number 2
# Photo frame: 12 in stock
# Admins only.
# None
# Teddy bear now costs Rs. 499
Key Takeaways
- A decorator is a function that takes a function and returns a new function with extra behaviour, like wrapping paper around a gift.
@my_decoratorabove a function is a shortcut forfunc = my_decorator(func).- Use
*args, **kwargsin the wrapper, call the original function and return its result. - Always add
@functools.wraps(func)to keep the original name and docstring. - A decorator with settings, like
@retry(times=3), needs one more outer function that returns the decorator. @property,@staticmethodand@classmethodare built-in decorators for classes.