Decorators in Python
In this lesson: Understand Python decorators step by step — functions as objects, wrappers, @syntax and real uses like timing. In Hindi & English.
What is a decorator?
A decorator is a function that takes another function and returns a new function with extra behaviour – without changing the original code. You write it once and attach it with a single line that starts with @.
Real projects use decorators everywhere: Flask uses @app.route to connect a URL to a function, Django uses @login_required to protect pages, and Python itself gives @property, @staticmethod and @functools.lru_cache.
| Without decorator | With decorator |
|---|---|
| Copy the same logging/timing code into 20 functions | Write the logic once, add @log_call above each function |
| Change the rule → edit 20 places | Change the rule → edit one decorator |
| Business logic mixed with checks | Function stays clean; checks live outside |
Step 1: functions are objects
Decorators work because in Python a function is a normal object. You can store it in a variable, pass it to another function and return it from a function.
def greet(name):
return f"Namaste, {name}!"
say = greet # no brackets: we store the function itself
print(say("Riya"))
print(type(greet))
def run_twice(func, value): # a function that receives a function
return func(value) + " " + func(value)
print(run_twice(greet, "Aman"))greet is the function object; greet() runs it and gives its result. A decorator receives greet, not greet().
Step 2: a function inside a function (closure)
An inner function can use variables of the outer function even after the outer function has finished. This “remembering” is called a closure, and every decorator is built on it.
def make_gst_calculator(rate):
def add_gst(amount):
return round(amount * (1 + rate / 100), 2)
return add_gst # return the inner function
gst_5 = make_gst_calculator(5) # sweets
gst_18 = make_gst_calculator(18) # electronics
print(gst_5(200))
print(gst_18(200))Step 3: your first decorator
Now combine the two ideas. log_call receives a function, defines a wrapper that prints a message and then calls the original, and returns the wrapper.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"→ calling {func.__name__} with {args} {kwargs}")
result = func(*args, **kwargs)
print(f"← {func.__name__} returned {result}")
return result
return wrapper
@log_call
def total_bill(price, qty, discount=0):
return price * qty - discount
total_bill(120, 3, discount=20)The line @log_call is only a shortcut. Python actually runs total_bill = log_call(total_bill) right after defining the function.
*args, **kwargslet the wrapper accept any arguments, so one decorator fits every function.- Always
return resultfrom the wrapper – forgetting it makes every decorated function returnNone.
Step 4: keep the name and docstring with functools.wraps
A plain wrapper hides the original function’s name and help text. That breaks debugging, documentation and frameworks like Flask that use the function name. functools.wraps copies them across.
import functools
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def proper(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def fees_a():
"""Return the monthly fee."""
@proper
def fees_b():
"""Return the monthly fee."""
print(fees_a.__name__, "|", fees_a.__doc__)
print(fees_b.__name__, "|", fees_b.__doc__)Add @functools.wraps(func) to every wrapper you write. It costs one line and prevents confusing bugs later.
Real example 1: measure how long a function takes
import functools, time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
ms = (time.perf_counter() - start) * 1000
print(f"{func.__name__} took {'under 50' if ms < 50 else 'over 50'} ms")
return result
return wrapper
@timer
def sum_of_squares(n):
return sum(i * i for i in range(n))
print(sum_of_squares(1000))We print “under/over 50 ms” instead of the exact number so the output is stable on every computer; in your own code print f"{ms:.2f} ms".
Real example 2: a decorator that takes arguments
To pass settings such as the number of retries, add one more outer level: a function that receives the settings and returns the decorator.
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 err:
print(f"attempt {attempt} failed: {err}")
raise ConnectionError(f"{func.__name__} failed {times} times")
return wrapper
return decorator
calls = {"n": 0}
@retry(times=3)
def send_sms(phone):
calls["n"] += 1
if calls["n"] < 3: # fail twice, then succeed
raise ConnectionError("network busy")
return f"SMS sent to {phone}"
print(send_sms("98290xxxxx"))| Level | Receives | Returns |
|---|---|---|
retry(times) | settings | decorator |
decorator(func) | the function | wrapper |
wrapper(*args) | the call arguments | the result |
Real example 3: allow only logged-in admins
import functools
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != role:
return f"403 Forbidden: {user['name']} is not {role}"
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_student(user, roll_no):
return f"Student {roll_no} deleted by {user['name']}"
teacher = {"name": "Meena", "role": "teacher"}
admin = {"name": "Gagan", "role": "admin"}
print(delete_student(teacher, 21))
print(delete_student(admin, 21))This is exactly how @login_required in Django and @jwt_required in Flask work: the check runs before your function, so you never forget it.
Real example 4: built-in caching with lru_cache
from functools import lru_cache
calls = 0
@lru_cache(maxsize=None)
def fib(n):
global calls
calls += 1
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(35))
print("function calls:", calls)
print(fib.cache_info())Without the cache, fib(35) makes almost 30 million calls; with it, only 36. Use caching for pure functions whose result depends only on the arguments.
Stacking decorators: order matters
import functools
def bold(func):
@functools.wraps(func)
def wrapper():
return "<b>" + func() + "</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper():
return "<i>" + func() + "</i>"
return wrapper
@bold
@italic
def title():
return "Result 2026"
print(title()) # same as bold(italic(title))The decorator closest to the function is applied first. Read the stack from bottom to top.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Forgetting return result in wrapper | Function returns None | Return the original result |
Writing @timer() for a decorator without arguments | TypeError: missing func | Use @timer; brackets only for decorator factories |
No functools.wraps | Wrong name in logs, Flask “endpoint overwriting” errors | Always add @functools.wraps(func) |
| Caching a function that reads time or a database | Stale, wrong answers | Cache only pure functions |
Practice tasks
- Write
@count_callsthat prints how many times a function has been called. - Write
@validate_positivethat raisesValueErrorif any number argument is negative. - Write
@cache_for(seconds)that remembers a result only for the given time (usetime.time()). - Apply
@timerand@log_calltogether and explain the printed order.
Summary
- A decorator takes a function and returns a new function with extra behaviour.
@nameabove a function meansfunc = name(func).- Use
*args, **kwargs, return the result and addfunctools.wraps. - Need settings? Add an outer function – a decorator factory.
- Typical uses: logging, timing, retries, permissions, caching, routing.
