Engineering Notes
Home/Languages/Python/Decorators
Languages · Python

Decorators

shout takes a function and returns a new function (wrapper) that calls the original and modifies its result. This pattern — wrap, call the original, do something before/after/with the result — is the entire idea behind decorators. The @ syntax is sugar for exactly the reassignment shown above.

💻 Languages● Intermediate⏱ 8 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • A Decorator Is Just a Function That Wraps Another Function
  • The @ Syntax
  • Preserving Metadata with functools.wraps
  • Multiple Decorators Stack Bottom-Up
  • Common Uses
Part 1The Core Mechanism

1A Decorator Is Just a Function That Wraps Another Function

python
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

def greet(name):
    return f"hello, {name}"

greet = shout(greet)   # manually "decorating" — reassign the name
print(greet("ana"))     # "HELLO, ANA"

shout takes a function and returns a new function (wrapper) that calls the original and modifies its result. This pattern — wrap, call the original, do something before/after/with the result — is the entire idea behind decorators. The @ syntax is sugar for exactly the reassignment shown above.

2The @ Syntax

python
def shout(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout
def greet(name):
    return f"hello, {name}"

print(greet("ana"))  # "HELLO, ANA"
python
@shout
def greet(name):
    return f"hello, {name}"

# is EXACTLY equivalent to:

def greet(name):
    return f"hello, {name}"
greet = shout(greet)

@decorator_name above a def runs decorator_name(the_function_just_defined) immediately after the function is compiled, and rebinds the name to whatever that call returns. This happens once, at definition time — not on every call to greet.

3Preserving Metadata with functools.wraps

python
import functools

def shout(func):
    @functools.wraps(func)   # copies __name__, __doc__, etc. from func onto wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout
def greet(name):
    """Return a greeting."""
    return f"hello, {name}"

print(greet.__name__)  # "greet" — WITHOUT @wraps this would be "wrapper"
print(greet.__doc__)    # "Return a greeting."

Without functools.wraps, the decorated function's __name__, __doc__, and other introspection metadata get silently replaced by wrapper's — breaking debuggers, help(), and any code that inspects the function. Always use @functools.wraps(func) inside a decorator's inner function.

4Multiple Decorators Stack Bottom-Up

python
def bold(func):
    def wrapper(*a, **kw):
        return f"<b>{func(*a, **kw)}</b>"
    return wrapper

def italic(func):
    def wrapper(*a, **kw):
        return f"<i>{func(*a, **kw)}</i>"
    return wrapper

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

print(text())  # "<b><i>hi</i></b>"

Stacked decorators apply bottom-to-top at definition time (the one closest to the function runs first, wrapping it; the next one wraps that result), but that means the resulting call order is outside-in: bold's wrapper runs first when you call text(), and it calls into italic's wrapper, which calls the real text.

5Common Uses

python
import time, functools

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timed
def slow_add(a, b):
    time.sleep(0.1)
    return a + b

Common real-world decorators: logging/timing (above), caching (functools.lru_cache), access control/authentication checks, retry logic, input validation, and registering a function into some registry (e.g. route decorators in web frameworks like Flask/FastAPI).

⚠️ Common Pitfalls

  1. Forgetting functools.wraps — breaks introspection, help(), and anything relying on __name__/__doc__; always include it.
  2. Forgetting *args, **kwargs in the wrapper — a wrapper defined as def wrapper(): ... can only decorate zero-argument functions; almost always write def wrapper(*args, **kwargs) unless you specifically need a fixed signature.
  3. Assuming decorators run on every call — the decorator function itself runs exactly once, at definition time; only the returned wrapper runs on every call.
  4. Misreading stacking order — @bold then @italic above a function applies italic first (closest to the def), but bold's wrapper executes first at call time (outermost).
Part 2Arguments, Classes, and Internals

1Decorators That Take Arguments — Three Layers of Functions

python
import functools

def repeat(times):              # layer 1: takes the decorator's OWN arguments
    def decorator(func):         # layer 2: takes the function being decorated
        @functools.wraps(func)
        def wrapper(*args, **kwargs):   # layer 3: the actual call-time wrapper
            results = []
            for _ in range(times):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator

@repeat(times=3)
def roll():
    import random
    return random.randint(1, 6)

print(roll())  # e.g. [4, 1, 6]

@repeat(times=3) first calls repeat(times=3), which returns decorator — that is what actually gets applied to roll (decorator(roll)), exactly like an argument-less decorator from Part 1. A parameterized decorator is a function that returns a decorator, which is why it needs one extra layer of nesting compared to a plain decorator.

2Class-Based Decorators

python
import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)   # class equivalent of @wraps
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

@CountCalls
def greet():
    return "hi"

greet(); greet()
print(greet.count)  # 2

Any object implementing __call__ can decorate a function — @CountCalls here means greet = CountCalls(greet), producing an instance of CountCalls that behaves like a callable, with the added benefit of easily holding extra state (self.count) that a closure-based decorator would need nonlocal for.

3Decorating Classes Themselves

python
def add_repr(cls):
    def __repr__(self):
        fields = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
        return f"{cls.__name__}({fields})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

print(Point(1, 2))  # Point(x=1, y=2)

A decorator can be applied to a class statement exactly like a function — it receives the class object itself and can return it modified, or return an entirely different object. dataclasses.dataclass is the most common real-world class decorator in the standard library, generating __init__/__repr__/__eq__ automatically.

4functools.lru_cache — A Decorator Built on These Ideas

python
import functools

@functools.lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

fib(30)  # fast — memoized, exponential recursion collapses to linear work
print(fib.cache_info())  # CacheInfo(hits=..., misses=..., maxsize=128, currsize=...)

lru_cache is itself a parameterized decorator (section 1's pattern) implemented in C for speed in CPython, storing a dict keyed by the call's arguments (which must therefore be hashable). It demonstrates why the mutable-default-argument-style closures matter: the cache dict is created once, when lru_cache(maxsize=128) produces the actual decorator, and persists across every call to fib for the lifetime of that function object.

Under the HoodWhat @ Actually Compiles To▾

The @ syntax is evaluated top-to-bottom, then applied bottom-to-top, at def/class compile time

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

compiles, roughly, to bytecode equivalent to:

python
def text():
    return "hi"
text = italic(text)   # closest decorator to the def applies FIRST
text = bold(text)      # then the next one up wraps that result

Both decorator expressions (bold, italic) are evaluated (looked up as names/attributes/calls) before either is applied, in top-to-bottom order — but the actual application (func = decorator(func)) happens bottom-to-top, working outward from the function definition. This is exactly why call order (section 4 of Part 1) ends up outside-in: the outermost decorator (bold) wraps a text that is already fully wrapped by italic.

@bold then @italic: decorator expressions evaluated top-down, but application happens bottom-up — text = bold(italic(text))
@bold then @italic: decorator expressions evaluated top-down, but application happens bottom-up — text = bold(italic(text))

There is no separate "decorator" concept at the bytecode/object level

Mechanically, a decorated function is completely ordinary once decoration finishes — there is no runtime flag, marker, or special object type indicating "this function was decorated." greet after @shout is just whatever shout(greet) returned; if that's a closure named wrapper, then greet is wrapper from that point on, full stop. Anything inspecting greet.__code__ after decoration sees wrapper's code object, not the original function's — which is precisely the introspection problem functools.wraps patches over (it copies __name__/__doc__/__dict__/__wrapped__ from the original onto the wrapper, and sets __wrapped__ so tools can still find the original if they know to look for it).

python
import functools

def shout(func):
    @functools.wraps(func)
    def wrapper(*a, **kw):
        return func(*a, **kw).upper()
    return wrapper

@shout
def greet(name):
    return f"hi {name}"

print(greet.__wrapped__)   # the ORIGINAL undecorated greet function object
print(greet.__wrapped__("ana"))  # "hi ana" — bypasses the decorator entirely

Parameterized decorators cost one extra closure layer, every time the module loads

@repeat(times=3) means repeat(times=3) actually executes at module-import time (or whenever the enclosing def/class is defined), producing a fresh decorator closure over times=3 — this closure allocation happens once per decorated definition, not once per call, using the same cell-object mechanism as any other closure (see the Variables and Scope topic's "Under the Hood" section).

⚠️ Common Pitfalls

  1. Forgetting the third layer for parameterized decorators — @repeat(times=3) requires repeat to return a decorator, which itself returns a wrapper; skipping a layer produces a TypeError about calling a function with the wrong number of arguments.
  2. Applying @functools.lru_cache to a method with mutable/unhashable arguments — the cache key is built from the arguments; passing a list or dict argument raises TypeError: unhashable type.
  3. Decorating instance methods without accounting for self — a decorator's wrapper needs to pass self through via *args, **kwargs like any other positional argument; forgetting this breaks methods specifically (functions work fine, methods don't automatically special-case anything).
  4. Assuming @classmethod/@staticmethod compose freely with custom decorators in any order — ordering matters; a custom decorator generally needs to be closer to the function (applied first / listed last, right above def) than @classmethod/@staticmethod, or it will receive the descriptor object instead of a plain callable.

📋 Quick Reference

python
def dec(func):
    @functools.wraps(func)
    def wrapper(*a, **kw): return func(*a, **kw)
    return wrapper                            # plain decorator

def param_dec(opt):
    def dec(func):
        @functools.wraps(func)
        def wrapper(*a, **kw): return func(*a, **kw)
        return wrapper
    return dec                                 # parameterized decorator: 3 layers

@bold
@italic
def f(): ...                                    # applied bottom-up: bold(italic(f))

f.__wrapped__                                    # original function, if @wraps was used
functools.lru_cache(maxsize=None)                 # built-in memoization decorator

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a decorator?core▾

A callable that takes a function (or class) and returns a replacement, applied with @decorator. It adds behaviour like logging, caching, timing, retries or access checks without changing the function body.

Follow-ups interviewers ask
  • Equivalent without @? f = decorator(f).
Q2Why use functools.wraps?core▾

It copies the wrapped function's __name__, __doc__, signature metadata and __wrapped__ to the wrapper, keeping introspection, debugging and docs correct.

Follow-ups interviewers ask
  • What breaks without it? Frameworks relying on names, docs and signatures.
Q3How do you write a decorator with arguments?core▾

Add another level: def retry(times): def deco(fn): @wraps(fn) def wrapper(*a, **k): … return wrapper; return deco, used as @retry(times=3).

Follow-ups interviewers ask
  • Class-based decorators? Classes implementing __call__.
Q4Which built-in decorators do you use?senior▾

@property, @classmethod, @staticmethod, @functools.cache/lru_cache, @dataclass, @contextmanager, @functools.singledispatch, @abstractmethod.

Follow-ups interviewers ask
  • lru_cache caveat? Arguments must be hashable; cache lives for the process.
Q5How do decorators work with async functions?core▾

The wrapper must be async def and await the wrapped coroutine; check with inspect.iscoroutinefunction to support both sync and async.

Follow-ups interviewers ask
  • Common bug? Sync wrapper returning an un-awaited coroutine.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.