Skip to content

Decorators Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on decorators, functools.wraps, decorators with arguments, class-based decorators, stacking order, and real-world use cases.

Read the in-depth guidePython Decorators Explained — Wrapping Functions, functools.wraps, and Decorators with Arguments(opens in new tab)
15 of 15

A decorator is a callable that takes a function and returns a (usually wrapped) function, letting you add behaviour without modifying the original. The @decorator syntax above a def is just sugar for reassigning the name to the decorator's result: func = decorator(func).

def log_calls(func):
    def wrapper(*args, **kwargs):    # accept any signature
        print(f"calling {func.__name__}")
        return func(*args, **kwargs) # delegate to the original
    return wrapper

@log_calls
def add(a, b):
    return a + b
# equivalent to: add = log_calls(add)

add(2, 3)   # prints "calling add", returns 5

This works because functions are first-class objects — they can be passed around and returned. Decorators are the idiomatic way to factor out cross-cutting concerns (logging, timing, caching, access control).

Without it, the wrapper replaces the original function's identity: the decorated object reports the wrapper's __name__, __doc__, signature, and __module__, which breaks introspection, debugging, and tools that rely on metadata. functools.wraps copies that metadata from the original onto the wrapper.

import functools

def log_calls(func):
    @functools.wraps(func)       # copy name, docstring, __wrapped__, etc.
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_calls
def greet():
    "say hello"
    ...

greet.__name__   # "greet"  (without wraps -> "wrapper")
greet.__doc__    # "say hello"

It also sets __wrapped__, so inspect.signature and unwrapping still work. Rule of thumb: always apply @functools.wraps(func) to your wrapper — it's effectively free and prevents subtle bugs.

You add another layer of nesting: an outer function takes the decorator's arguments and returns the actual decorator, which takes the function and returns the wrapper. So @repeat(3) first calls repeat(3) to get a decorator, which is then applied to the function.

import functools

def repeat(n):                       # takes the decorator argument
    def decorator(func):             # takes the function
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)                           # repeat(3) returns 'decorator'
def ping():
    print("pong")

The mental model: @repeat(3) is ping = repeat(3)(ping) — three calls deep. Remember the parentheses: @repeat(3) (with args) differs from @repeat (passing the function directly), and forgetting them is a common bug.

A class becomes a decorator by being callable — define __call__. The __init__ receives the decorated function; __call__ runs the wrapping logic on each invocation. This is handy when the decorator needs to hold state (like a call count) in a clean, attribute-based way.

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)  # the class-based wraps
        self.func = func
        self.count = 0
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"call #{self.count}")
        return self.func(*args, **kwargs)

@CountCalls
def hello():
    print("hi")

hello(); hello()      # "call #1" then "call #2"
hello.count           # 2 — state lives on the instance

Use functools.update_wrapper (the function-form of wraps) to preserve metadata. Class decorators shine for stateful decorators; for simple stateless ones, a nested function with a nonlocal closure is usually lighter.

Decorators apply bottom-up (nearest the function first) at definition time, but the resulting wrappers execute top-down at call time. Stacking is just nested application: the top decorator wraps the result of the ones below it.

@a
@b
def f(): ...
# equivalent to: f = a(b(f))   — b wraps first, a wraps outermost

def bold(fn):
    return lambda: "<b>" + fn() + "</b>"
def italic(fn):
    return lambda: "<i>" + fn() + "</i>"

@bold
@italic
def text():
    return "hi"

text()   # "<b><i>hi</i></b>"  — bold is outer, runs around italic

So the closest decorator is applied first but its logic runs innermost. Order matters whenever decorators have side effects or transform results — e.g. put @staticmethod outermost, or @app.route above @login_required so auth runs before the view.

@dec above a definition is just func = dec(func) — the decorator is called with the function and its return value rebinds the name. That's the whole mechanism; everything else is convention.

@log
def greet(): ...
# identical to:
def greet(): ...
greet = log(greet)

Rule of thumb: read @dec as "replace the name with dec(name)".

The decorated function reports the wrapper's __name__, __doc__, and signature instead of the original's — breaking help(), debuggers, introspection, and some frameworks (e.g. ones that read function names for routing). @wraps(fn) copies that metadata over.

from functools import wraps
def log(fn):
    @wraps(fn)               # without this, greet.__name__ == 'inner'
    def inner(*a, **k): return fn(*a, **k)
    return inner

Rule of thumb: always wrap the inner function with @wraps(fn) so the decorated function keeps its identity.

Make the argument optional and detect whether the first positional is the function itself. If called as @dec the function is passed directly; if @dec(...) it isn't. Use a keyword-only config plus a func=None check.

from functools import wraps, partial
def retry(func=None, *, times=3):
    if func is None:
        return partial(retry, times=times)   # called as @retry(times=5)
    @wraps(func)
    def inner(*a, **k):
        for _ in range(times):
            try: return func(*a, **k)
            except Exception: pass
    return inner

Rule of thumb: return partial(dec, **opts) when the function slot is empty, so both @dec and @dec(...) work.

Yes — a class decorator receives the class and returns a (usually modified) class. It's used to register classes, inject methods/attributes, or wrap them. @dataclass is the canonical example. It runs after the class body executes.

registry = {}
def register(cls):
    registry[cls.__name__] = cls
    return cls

@register
class Plugin: ...

Rule of thumb: use class decorators to augment or register a class without subclassing or a metaclass.

They apply bottom-up at definition time (the nearest decorator wraps first), but execute top-down at call time (the outermost runs first). @a @b def f means a(b(f)).

@bold          # outer: runs second when called, wraps last
@italic        # inner: runs first when called, wraps first
def text(): return "hi"
# text = bold(italic(text))

Rule of thumb: read the stack as nested calls — bottom decorator is innermost, top is outermost.

@property turns a method into a managed attribute with getter semantics; @x.setter and @x.deleter add write/delete behavior. It's a descriptor that runs your method on attribute access, enabling computed or validated attributes without changing call sites.

class C:
    @property
    def value(self): return self._v
    @value.setter
    def value(self, v):
        if v < 0: raise ValueError
        self._v = v

c = C(); c.value = 5        # calls the setter

Rule of thumb: use @property to expose computed/validated attributes that look like plain attribute access.

The decorator call (and any setup outside the wrapper) runs once, at import/definition time. Only the inner wrapper runs on each call. So registration, validation, or logging placed in the decorator body executes when the module loads, not per call.

def trace(fn):
    print("decorating", fn.__name__)   # runs at import
    def inner(*a, **k):
        print("calling")               # runs each call
        return fn(*a, **k)
    return inner

Rule of thumb: put per-call logic in the inner wrapper; one-time setup goes in the decorator body.

@staticmethod makes a method that takes no implicit first arg — just a namespaced plain function. @classmethod passes the class as cls, enabling alternative constructors and class-level behavior. Both are descriptors applied via decorator syntax.

class Date:
    @classmethod
    def today(cls):           # cls = Date (or a subclass)
        return cls(...)
    @staticmethod
    def is_leap(y):           # no self/cls
        return y % 4 == 0

Rule of thumb: classmethod for alternative constructors/factory methods; staticmethod for utility functions logically grouped under a class.

Store it in the enclosing closure (a captured variable) or on the wrapper function's attributes. A class-based decorator can keep state on self. Use this for counters, caches, or rate limiters.

from functools import wraps
def count_calls(fn):
    @wraps(fn)
    def inner(*a, **k):
        inner.calls += 1
        return fn(*a, **k)
    inner.calls = 0
    return inner

Rule of thumb: keep decorator state in the closure or on the wrapper/self, not in globals.

It's factory → decorator → wrapper: the outermost function takes the arguments and returns a decorator; that decorator takes the function and returns the wrapper that runs at call time. Three nested defs.

from functools import wraps
def repeat(n):                       # 1) takes args
    def decorator(fn):               # 2) takes the function
        @wraps(fn)
        def wrapper(*a, **k):        # 3) runs at call time
            for _ in range(n):
                r = fn(*a, **k)
            return r
        return wrapper
    return decorator

@repeat(3)
def hi(): print("hi")

Rule of thumb: a parametrized decorator needs three layers — remember args-layer, function-layer, call-layer.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel