The Story That Explains Decorators
That is exactly what a decorator does in Python. It takes a function (the "gift"), wraps extra behaviour around it (logging, timing, authentication, caching), and returns the wrapped version — without ever modifying the original function's code. The same decorator can wrap dozens of different functions.
In Python, functions are first-class objects. You can pass them to other functions, return them from functions, and store them in variables. A decorator is simply a function (or class) that takes another function as input and returns a new function that usually calls the original — but with something extra bolted on before, after, or around it.
A decorator lets you add reusable behaviour — logging, timing, access control,
caching, retries — to any function by writing @my_decorator on the line
above it. One line. Zero changes to the function itself. This is Python's most elegant
way to achieve separation of concerns.
The Foundation — Functions Are Objects
Before decorators make sense, you must accept one fact: in Python, a function is just another object. You can assign it to a variable, put it in a list, pass it as an argument, or return it from another function.
def greet(name):
return f"Hello, {name}!"
# 1. Assign the function to a new name (no parentheses!)
say_hi = greet
print(say_hi("Mohit")) # -> Hello, Mohit!
# 2. Pass a function as an argument
def shout(fn, name):
return fn(name).upper()
print(shout(greet, "Mohit")) # -> HELLO, MOHIT!
# 3. Return a function from another function
def make_multiplier(n):
def multiplier(x):
return x * n
return multiplier
double = make_multiplier(2)
print(double(7)) # -> 14
Notice how multiplier remembered the value of n even after
make_multiplier finished executing. This is called a closure —
an inner function that captures variables from its enclosing scope. Every decorator you
write relies on this mechanism.
Your First Decorator — Written by Hand
Let's build a decorator that prints a message before and after any function runs.
We will do it without the @ syntax first, so you see what's really happening.
def announce(fn): # takes a function
def wrapper(): # builds a new function
print("-- before --")
result = fn() # call the original
print("-- after --")
return result
return wrapper # returns the wrapper
def say_hello():
print("Hello!")
# Manually wrap it
say_hello = announce(say_hello)
say_hello()
Now the same thing using the @ syntax — this is pure syntactic sugar
for say_hello = announce(say_hello). Nothing more, nothing less.
def announce(fn):
def wrapper():
print("-- before --")
result = fn()
print("-- after --")
return result
return wrapper
@announce # the magic line
def say_hello():
print("Hello!")
say_hello()
When Python sees @announce above a function definition, it executes
say_hello = announce(say_hello) immediately after the definition.
That's the entire trick. Decorators are not a new language feature — they're a
convenient way to write function reassignment.
Handling Arguments — *args and **kwargs
Real functions take arguments. To make your decorator work with any function
regardless of its signature, use *args, **kwargs in the wrapper. This
captures every positional and keyword argument and forwards them intact.
def announce(fn):
def wrapper(*args, **kwargs):
print(f"Calling {fn.__name__} with {args} {kwargs}")
result = fn(*args, **kwargs)
print(f"{fn.__name__} returned {result!r}")
return result
return wrapper
@announce
def add(a, b):
return a + b
@announce
def greet(name, greeting="Hi"):
return f"{greeting}, {name}!"
add(3, 4)
greet("Mohit", greeting="Namaste")
A common bug: forgetting return result inside the wrapper. If your original
function returns something, the wrapper must return it too — otherwise the decorated
function silently starts returning None. This breaks silently and is a nightmare
to debug.
Preserving Metadata — functools.wraps
When you decorate a function, its __name__, __doc__, and other
introspection attributes get replaced by the wrapper's. This confuses debuggers, IDEs,
and documentation tools. The fix is one line: @functools.wraps(fn).
| Attribute | Value |
|---|---|
| add.__name__ | 'wrapper' |
| add.__doc__ | None |
| help(add) | Shows wrapper |
| Attribute | Value |
|---|---|
| add.__name__ | 'add' |
| add.__doc__ | 'Add two numbers.' |
| help(add) | Shows add |
from functools import wraps
def announce(fn):
@wraps(fn) # preserves metadata
def wrapper(*args, **kwargs):
print(f"Calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@announce
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # -> add (not 'wrapper')
print(add.__doc__) # -> Add two numbers.
Every decorator you write in production code must use
@functools.wraps(fn) on its wrapper. It costs nothing and prevents
broken tracebacks, misleading logs, and unusable documentation.
Visual Diagram — How a Decorator Wraps a Function
This is the mental model. The original function is the gift; the decorator is the wrapping paper. Once wrapped, every call goes through the wrapper — which does its "before" work, calls the inner function, does its "after" work, and returns the result.
The wrapper "sandwiches" the original function between before and after logic. The green box (original) is never edited — the decorator only replaces the name in the namespace.
Execution Timeline — What Happens When You Write @decorator
Phase 1 rebinds the name once. Phase 2 is what users see — every call flows through the wrapper.
Decorators That Take Arguments
What if you want @repeat(3) to run a function three times, or
@retry(times=5) to retry on failure? Now the decorator itself needs arguments.
This requires three levels of nested functions:
times=3).
fn.
*args, **kwargs and executes.
@repeat(3) above def greet(name) desugars to
greet = repeat(3)(greet). Two chained calls → two extra layers of nesting.
Each layer captures its own set of arguments via closure.
Call Flow: @repeat(3) → greet("Mohit")
Steps 1–2 run once. Steps 3–4 run on every call. This is why decorator arguments (like times=3) are captured in a closure that survives across all calls.
from functools import wraps
def repeat(times): # Layer 1 — takes decorator args
def decorator(fn): # Layer 2 — real decorator
@wraps(fn)
def wrapper(*args, **kwargs): # Layer 3 — runtime
result = None
for _ in range(times):
result = fn(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3) # note: @repeat(3), not @repeat
def greet(name):
print(f"Hi, {name}!")
greet("Mohit")
@repeat(3) is really repeat(3)(greet). First repeat(3)
is called and returns the actual decorator, then that decorator is applied to
greet. Two calls → two extra levels of nesting. This is the standard
pattern for every parameterised decorator you'll ever write.
Practical Example — @timer, @retry, @cache
@timer — measure execution time
import time
from functools import wraps
def timer(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__} took {elapsed*1000:.2f} ms")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000)
@retry — auto-retry on exception (takes arguments)
import time, random
from functools import wraps
def retry(times=3, delay=1.0):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except Exception as e:
last_error = e
print(f"[attempt {attempt}] failed: {e}")
time.sleep(delay)
raise last_error
return wrapper
return decorator
@retry(times=3, delay=0.5)
def flaky_api_call():
if random.random() < 0.7:
raise ConnectionError("server busy")
return "OK"
print(flaky_api_call())
@cache — memoise expensive results
from functools import wraps
def cache(fn):
stored = {}
@wraps(fn)
def wrapper(*args):
if args not in stored:
stored[args] = fn(*args)
return stored[args]
return wrapper
@cache
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
print(fib(50)) # instant, without cache would hang
The @cache above is a teaching version. In production use
@functools.lru_cache(maxsize=128) — it's C-optimised, thread-safe, and
supports keyword arguments and size limits.
Class-Based Decorators
A decorator doesn't have to be a function — any callable works. A class becomes a
decorator by implementing __init__ (receives the function) and
__call__ (runs when the decorated function is invoked).
*args, **kwargs,
do your before/after work, call self.fn(...), return the result.
CountCalls — a class-based decorator with state
from functools import wraps
class CountCalls:
def __init__(self, fn):
wraps(fn)(self) # preserve metadata
self.fn = fn
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.fn.__name__} call #{self.count}")
return self.fn(*args, **kwargs)
def reset(self):
self.count = 0
@CountCalls
def say_hi(name):
print(f"Hi, {name}!")
say_hi("Mohit")
say_hi("Aarav")
say_hi("Priya")
print(f"Total: {say_hi.count} calls") # -> 3
say_hi.reset()
print(f"After reset: {say_hi.count}") # -> 0
Class-based decorator that takes arguments
from functools import wraps
class Repeat:
def __init__(self, times): # Layer 1 — receives args
self.times = times
def __call__(self, fn): # Layer 2 — receives fn
@wraps(fn)
def wrapper(*args, **kwargs): # Layer 3 — runtime
result = None
for _ in range(self.times):
result = fn(*args, **kwargs)
return result
return wrapper
@Repeat(2)
def shout(msg):
print(msg.upper())
shout("hello")
Function vs Class-Based — When to Use Which
| Aspect | Function-Based | Class-Based |
|---|---|---|
| Syntax weight | Lightweight — a few nested defs | Heavier — needs a class body |
| Persistent state | Possible via closures or nonlocal | Natural — use self.attr |
| Extra methods (reset, stats) | Awkward — attach via .attribute | First-class — just add methods |
| Readability for beginners | Reads top-to-bottom | Requires OOP understanding |
| Best fit | Small, stateless enhancements (log, time) | Stateful (counters, rate-limiters, registries) |
Start with a function decorator. If you find yourself using
nonlocal to mutate state, or attaching methods like .reset()
or .stats() to the wrapper, that's your signal to refactor into a
class-based decorator.
Stacking Multiple Decorators
You can stack decorators. They apply bottom-up — the decorator closest to the function wraps it first, then the next one wraps that, and so on. But the wrappers execute top-down when the function is called.
def bold(fn):
def wrapper(*a, **k):
return f"<b>{fn(*a, **k)}</b>"
return wrapper
def italic(fn):
def wrapper(*a, **k):
return f"<i>{fn(*a, **k)}</i>"
return wrapper
@bold # applied SECOND — outermost
@italic # applied FIRST — innermost
def say(msg):
return msg
print(say("hello"))
# Equivalent to: say = bold(italic(say))
Left: how the decorators wrap the function (innermost decorator runs first at definition time). Right: how a call flows through them (outermost decorator's before code runs first at call time).
Reversing to @italic @bold would produce <i><b>hello</b></i>.
For security-sensitive stacks like @login_required @cache, always put
authentication decorators outermost so they run first and can reject
unauthorised calls before any cached data is even touched.
Real-World Example — @login_required
Now let's build the classic authentication decorator. In web frameworks like Flask and
Django, @login_required is the canonical use case for decorators — it
guards view functions so only authenticated users can call them.
The decorator is a gate. Unauthenticated calls are rejected before the view function ever executes. The view function stays pure business logic — no if user is None clutter.
Version 1 — Simple @login_required
from functools import wraps
# Fake session — in a real app this comes from Flask/Django
current_session = {"user": None}
def login_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
user = current_session.get("user")
if user is None:
raise PermissionError("Login required")
return fn(*args, **kwargs)
return wrapper
@login_required
def view_dashboard():
return f"Welcome, {current_session['user']['name']}!"
# --- Try it without logging in ---
try:
view_dashboard()
except PermissionError as e:
print(f"Blocked: {e}")
# --- Log in and try again ---
current_session["user"] = {"name": "Mohit", "role": "admin"}
print(view_dashboard())
Version 2 — @requires_role("admin") with arguments
Now let's parameterise it. We want @requires_role("admin") to only allow
users whose role matches. This uses the three-layer pattern from Section 07.
from functools import wraps
current_session = {"user": None}
def requires_role(*allowed_roles): # Layer 1 — decorator args
def decorator(fn): # Layer 2 — real decorator
@wraps(fn)
def wrapper(*args, **kwargs): # Layer 3 — runtime
user = current_session.get("user")
if user is None:
raise PermissionError("Login required")
if user["role"] not in allowed_roles:
raise PermissionError(
f"Role '{user['role']}' not in {allowed_roles}"
)
return fn(*args, **kwargs)
return wrapper
return decorator
@requires_role("admin")
def delete_all_users():
return "All users deleted!"
@requires_role("admin", "editor")
def publish_article(title):
return f"Published: {title}"
# --- Editor tries admin-only action ---
current_session["user"] = {"name": "Priya", "role": "editor"}
try:
delete_all_users()
except PermissionError as e:
print(f"Blocked: {e}")
print(publish_article("Python Decorators"))
# --- Admin can do both ---
current_session["user"] = {"name": "Mohit", "role": "admin"}
print(delete_all_users())
Version 3 — Class-Based @LoginRequired with Audit Log
Class-based version — persistent state lets us log every attempt and expose statistics.
from functools import wraps
from datetime import datetime
current_session = {"user": None}
class LoginRequired:
def __init__(self, fn):
wraps(fn)(self)
self.fn = fn
self.granted = 0
self.denied = 0
self.log = []
def __call__(self, *args, **kwargs):
user = current_session.get("user")
ts = datetime.now().strftime("%H:%M:%S")
if user is None:
self.denied += 1
self.log.append((ts, "DENIED", self.fn.__name__))
raise PermissionError("Login required")
self.granted += 1
self.log.append((ts, "GRANTED", user["name"]))
return self.fn(*args, **kwargs)
def stats(self):
return {"granted": self.granted, "denied": self.denied}
@LoginRequired
def view_profile():
return f"Profile of {current_session['user']['name']}"
# Simulate a mix of logged-in and logged-out attempts
for user in [None, {"name": "Mohit"}, None, {"name": "Aarav"}]:
current_session["user"] = user
try:
view_profile()
except PermissionError:
pass
print(view_profile.stats())
for entry in view_profile.log:
print(entry)
Everything here — auth check, role check, audit log, stats endpoint — comes from
one decorator. The view_profile function itself has zero authentication
code inside it. Business logic and security concerns stay cleanly separated. This is
why Flask's @login_required and Django's @permission_required
are decorators.
Built-in Decorators You Should Know
| Decorator | Purpose | Where It Lives |
|---|---|---|
@staticmethod |
Method that doesn't need self or cls |
built-in |
@classmethod |
Method that receives the class cls instead of self |
built-in |
@property |
Access a method like an attribute (obj.name not obj.name()) |
built-in |
@functools.wraps |
Preserve metadata of decorated function | functools |
@functools.lru_cache |
Memoise function results (with size limit) | functools |
@functools.cached_property |
Compute once, cache on instance | functools |
@dataclass |
Auto-generate __init__, __repr__, __eq__ |
dataclasses |
@contextmanager |
Turn a generator into a with-compatible context manager |
contextlib |
class User:
def __init__(self, first, last):
self._first = first
self._last = last
@property # access as attribute
def full_name(self):
return f"{self._first} {self._last}"
@classmethod # alternative constructor
def from_string(cls, s):
first, last = s.split(" ", 1)
return cls(first, last)
@staticmethod # utility, no self/cls
def is_valid_name(s):
return len(s.split()) >= 2
u = User.from_string("Mohit Kumar")
print(u.full_name) # -> Mohit Kumar (no parens!)
print(User.is_valid_name("Mohit Kumar"))
Common Pitfalls
@deco. Parameterised decorator uses @deco() — even with zero args, the parentheses are required.self. Since your wrapper uses *args, it captures self automatically. No special handling needed.decorated.__wrapped__ when you used @wraps. Handy for unit tests.Golden Rules
@functools.wraps(fn) on your wrapper.
It preserves __name__, __doc__, and __wrapped__.
Skipping it breaks tracebacks, docs, and framework introspection.
*args, **kwargs in the wrapper and forward them
untouched. This makes your decorator work with any function signature — including
methods where the first arg is self.
fn(*args, **kwargs).
Forgetting this makes the decorated function silently return None
— one of the hardest bugs to spot in code review.
@repeat(3) is really repeat(3)(fn).
@login_required,
@requires_role) go outermost. Caching decorators go innermost.
This prevents unauthorised access to cached data.
.reset(), .stats()), or clear OOP structure.
functools.lru_cache, functools.cached_property,
dataclasses.dataclass, and contextlib.contextmanager already
exist. Reach for them before writing your own.