Python Decorators
The Core Concept
A decorator is a function that takes another function as an argument, adds behavior around it, and returns a new (usually wrapped) function — without permanently modifying the original function's source. This works specifically because Python functions are first-class objects (can be passed around, returned, assigned) combined with closures (see [[Python Scoping & Closures]]) letting the wrapper remember the original function even after the decorator itself has finished running.
from functools import wraps
def log(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper
@log
def greet(name):
print(f"Hello, {name}")
greet("Vatsal")
# Calling greet
# Hello, Vatsal
# Done greet
@log above def greet is exactly equivalent to greet = log(greet) — the decorator syntax is sugar, not new machinery.
Why functools.wraps Is Non-Negotiable
Without @wraps(func), the wrapper function silently replaces the original's __name__, __doc__, and other metadata — greet.__name__ would become "wrapper" instead of "greet". This isn't cosmetic: it breaks introspection-dependent tooling (debuggers, help(), and critically, web frameworks like Flask that register routes by function name — every decorated route would collide under the same name "wrapper"). Always mention @wraps unprompted when discussing decorators in an interview; it's a specific, checkable signal that you've actually used decorators in real code, not just read about them.
Decorators With Arguments — the 3-Level Nested Pattern
A decorator that itself takes configuration arguments (e.g., @retry(times=3)) requires three nested levels, not two:
def repeat(n): # LEVEL 1: the factory — receives config args
def decorator(func): # LEVEL 2: the actual decorator — receives the function
@wraps(func)
def wrapper(*args, **kwargs): # LEVEL 3: the wrapper — runs on every call
for _ in range(n):
func(*args, **kwargs)
return wrapper
return decorator
@repeat(3)
def say_hi():
print("Hi!")
say_hi()
# Hi!
# Hi!
# Hi!
@repeat(3) first calls repeat(3), which returns decorator — that is what actually gets applied to say_hi. Each level's job: Level 1 (factory) captures configuration (n); Level 2 (decorator) captures the function being wrapped; Level 3 (wrapper) is what actually executes on each call, with access to both n (from Level 1's closure) and func (from Level 2's closure) — a closure chain, not a single closure.
A Real Production Example — Session Validation
def require_valid_session(return_json=False, update_activity=True):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
is_valid, reason = check_session_validity(session)
if not is_valid:
session.clear()
if return_json:
return jsonify({"error": "Session expired"}), 401
return redirect(url_for("main.login"))
if update_activity:
update_activity_timestamp(session)
return f(*args, **kwargs) # only reached if the session was valid
return decorated_function
return decorator
This demonstrates the real payoff of decorators: separation of concerns. The route handler function itself contains zero session-checking logic — that's entirely extracted into the decorator, applied declaratively (@require_valid_session(return_json=True)) rather than repeated inline in every route that needs it. This is the DRY principle in concrete, checkable form.
Dependency Injection via Decorator Arguments
A decorator can accept a function as configuration, not just static values — letting the same decorator enforce different logic per use site:
def require_authorization(check_func):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not check_func():
return {"error": "forbidden"}, 403
return f(*args, **kwargs)
return wrapper
return decorator
@require_authorization(check_func=is_admin)
def delete_user(user_id): ...
@require_authorization(check_func=is_owner_or_admin)
def edit_post(post_id): ...
The authorization logic is injected per route rather than hardcoded inside the decorator — the decorator itself stays generic and reusable across very different authorization rules.
Why *args, **kwargs in the Wrapper
The wrapper must accept and forward any signature the original function might have — since the same decorator can be applied to functions with completely different parameter lists. Without *args, **kwargs, a decorator would only work on functions matching one specific signature, defeating the point of writing a reusable decorator at all.
Stacking Multiple Decorators — Execution Order
@decorator_a
@decorator_b
@decorator_c
def f():
print("f")
Equivalent to f = decorator_a(decorator_b(decorator_c(f))) — application happens bottom-up (closest to the function first), because that's the order Python evaluates nested function calls.
def make(tag):
def decorator(func):
def wrapper(*args, **kwargs):
print(f"{tag} start")
result = func(*args, **kwargs)
print(f"{tag} end")
return result
return wrapper
return decorator
@make("A")
@make("B")
@make("C")
def f():
print("f")
f()
# A start
# B start
# C start
# f
# C end
# B end
# A end
Application order (decoration time, bottom-up): C wraps f, B wraps (C-wrapped f), A wraps (B-wrapped f). Execution order (call time, outermost-in): A's wrapper runs first since it's the outermost layer, then calls into B's wrapper, then C's, then finally the real f — then unwinds back out (C end → B end → A end), same nesting logic as any call stack. The rule that resolves both directions at once: closest-to-the-function decorator applies first, but the outermost decorator's wrapper code runs first — it's literally a call stack, A(B(C(f))), so A's wrapper is the outermost frame.
Interview Drills
- What does
@logabove a function definition actually do, mechanically? — It's syntactic sugar forfunc = log(func)— the namefuncin the enclosing scope is rebound to whateverlog(func)returns (typically a wrapper function), executed once at definition time, not on every call. - Why does a decorator with configuration arguments need 3 levels of nesting instead of 2? — The outermost level receives and closes over the configuration arguments; only after that factory is called (via
@decorator_name(args)) does it return the actual 2-level decorator (function-receiver + wrapper) — with 2 levels alone, there'd be nowhere to receive the configuration arguments before the function itself arrives. - What breaks if you omit
@wraps(func)in a decorator, and why does it matter in a real framework like Flask? — The wrapped function's__name__/__doc__get silently replaced by the wrapper's own metadata; in Flask specifically, routes are often registered/tracked by function name internally, so every decorated route would appear to share the name"wrapper", causing route-registration collisions or broken introspection — a real, checkable bug, not a cosmetic one. - With
@a @b @cstacked abovedef f(), which decorator's code runs first whenf()is called? —a's wrapper runs first, even thoughcwas applied closest tofat decoration time (ais the outermost call ina(b(c(f)))) — application order is bottom-up, execution order is outermost-in, and conflating the two is the classic wrong answer.
Cross-links
[[Python Scoping & Closures]] · [[Python OOP & Data Model]]