Back to Notes

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 decoratorthat 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

  1. What does @log above a function definition actually do, mechanically? — It's syntactic sugar for func = log(func) — the name func in the enclosing scope is rebound to whatever log(func) returns (typically a wrapper function), executed once at definition time, not on every call.
  2. 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.
  3. 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]]