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.
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.
Cross-links
[[Python Scoping & Closures]] · [[Python OOP & Data Model]]