Decorators
A decorator is a function that takes a function and returns a (usually enhanced) function — the @decorator syntax is just readable sugar for that call.
Decorators let you wrap a function or method with extra behavior — logging, timing, caching, access checks, retries — without changing the function's own source code. Under the hood a decorator is nothing exotic: it's an ordinary higher-order function that accepts a callable and returns a callable, and @decorator above a def is syntactic sugar for reassigning the function name to the decorator's result.
Functions as First-Class Objects
Decorators only make sense because Python functions are first-class objects: they can be assigned to variables, stored in data structures, passed as arguments, and returned from other functions, exactly like any other value.
shout and say are two names bound to the same function object; calling either invokes the same code.
def shout(text: str) -> str:
return text.upper() + "!"
say = shout # assign the function itself (no call — no parentheses)
print(say("hello")) # HELLO!
print(shout is say) # True — same function object, two namesA Decorator Is a Function That Returns a Function
The core pattern: define an outer function that accepts a function as its argument, define an inner 'wrapper' function that calls the original (plus does something extra), and return the wrapper. This relies on closures (a related concept) — the wrapper captures the original function as a free variable.
loud(add) returns wrapper, and reassigning the name 'add' to that wrapper means every future call to add() actually runs wrapper(), which calls the original add internally.
def loud(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
def add(a, b):
return a + b
add = loud(add) # manual decoration
print(add(2, 3))The @decorator Syntax
Writing @loud directly above a def is exactly equivalent to defining the function and then reassigning its name to loud(function) — it's purely syntactic sugar, applied at function-definition time (when the module is imported/executed), not at call time.
By the time add(2, 3) is called, 'add' already refers to wrapper, not the original function — the decoration happened once, right after the def statement ran.
@loud
def add(a, b):
return a + b
# equivalent, without @ sugar:
# def add(a, b):
# return a + b
# add = loud(add)
print(add(2, 3))
# calling add
# add returned 5
# 5Passing Arguments Through the Wrapper
A general-purpose decorator should work on functions with any signature. The wrapper accepts *args, **kwargs and forwards them unchanged to the wrapped function, so the decorator doesn't need to know or care what arguments the original function takes.
*args, **kwargs makes wrapper transparent to caller signature — it works whether the wrapped function takes zero, one, or many positional/keyword arguments.
def timed(func):
import time
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
@timed
def greet(name: str, *, excited: bool = False) -> str:
return f"Hi {name}!" if excited else f"Hi {name}."
greet("Ana", excited=True)functools.wraps: Preserving Metadata
Without help, a decorated function 'loses its identity': add.__name__ becomes 'wrapper', add.__doc__ becomes wrapper's (usually empty) docstring, and introspection tools (help(), documentation generators, debuggers) show the wrong information. The standard library's functools.wraps decorator, applied to the wrapper, copies over __name__, __doc__, __module__, __qualname__, and sets __wrapped__ to point at the original function.
functools.wraps(func) is itself a decorator (a decorator factory, applied to wrapper) that copies func's metadata onto wrapper before wrapper replaces the original name.
Always use functools.wraps in decorators meant for reuse. Omitting it is one of the most common decorator mistakes and silently breaks debugging, documentation, and anything that inspects __name__.
import functools
def loud(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@loud
def add(a: int, b: int) -> int:
"""Return the sum of a and b."""
return a + b
print(add.__name__) # 'add', not 'wrapper'
print(add.__doc__) # 'Return the sum of a and b.'Decorators with Arguments: Decorator Factories
@decorator calls decorator(func) directly. To support @decorator(arg) syntax, you need one more layer of function nesting: an outer function that takes the decorator's own arguments and RETURNS the actual decorator function, which then takes func and returns the wrapper. This three-level nesting is called a decorator factory.
@repeat(times=3) first calls repeat(times=3), which returns 'decorator'; THAT is what actually decorates wave, i.e. it's equivalent to wave = repeat(times=3)(wave).
import functools
def repeat(times: int):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def wave():
print("👋")
wave() # prints 👋 three timesDecorating Methods
The same pattern applies to instance methods — the wrapper's *args, **kwargs naturally includes self as the first positional argument, since decoration happens on the underlying function before it becomes a bound method.
self flows through *args like any other positional argument; the decorator doesn't need special handling for methods versus plain functions.
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
print(f"{type(self).__name__}.{func.__name__} called")
return func(self, *args, **kwargs)
return wrapper
class Account:
def __init__(self, balance: float):
self.balance = balance
@log_call
def deposit(self, amount: float) -> None:
self.balance += amount
Account(100).deposit(50) # Account.deposit calledReal-World Standard Library Decorators
Several familiar built-ins and standard-library tools are themselves decorators: @property, @staticmethod, @classmethod change how a method is accessed; @functools.lru_cache memoizes results; @functools.singledispatch enables function overloading by argument type. Recognizing the pattern helps you read these as 'ordinary functions that transform a function/method' rather than special syntax.
lru_cache is a decorator factory just like the custom 'repeat' example above: it takes configuration (maxsize) and returns the actual decorator.
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # fast — repeated calls are cachedMental Model: Decoration Happens Once, at Definition Time
A common misconception is that the decorator's setup code runs on every call. In fact, the outer decorator function body runs exactly once, when the def statement executes (typically at import time). Only the innermost wrapper function's body runs on every call to the decorated name.
Separating 'what runs once at decoration time' from 'what runs on every call' is the key to reasoning correctly about decorator behavior and side effects.
@repeat(times=3) # 1. repeat(times=3) runs ONCE -> returns 'decorator'
def wave(): # 2. decorator(wave) runs ONCE -> returns 'wrapper'
print("wave") # (wave itself is now captured inside wrapper's closure)
wave() # 3. EVERY call to wave() now actually runs wrapper()Key Takeaways
A decorator is a callable that accepts a function and returns a (usually wrapping) callable; @decorator is sugar for name = decorator(name).
The wrapper should accept and forward *args, **kwargs to remain usable on functions of any signature.
Always apply functools.wraps(func) to the wrapper to preserve __name__, __doc__, and other metadata.
A decorator that itself takes arguments needs an extra layer of nesting: a decorator factory returning the actual decorator.
Decoration runs once, at function-definition time; only the wrapper's body runs on every call.
property, staticmethod, classmethod, and functools.lru_cache/singledispatch are all built on this same pattern.
def loud(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @loud def add(a, b): return a + b print(add.__name__) # 'wrapper' — metadata lost import functools def loud(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @loud def add(a, b): return a + b print(add.__name__) # 'add'
Without functools.wraps, the wrapper replaces the original function's identity entirely, breaking introspection, documentation tools, and anything relying on __name__.
def once(func): def wrapper(x): # only accepts one positional arg return func(x) return wrapper @once def add(a, b): return a + b add(1, 2) # TypeError: wrapper() takes 1 positional argument but 2 were given def once(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @once def add(a, b): return a + b add(1, 2) # 3
A general-purpose decorator's wrapper must accept *args, **kwargs and forward them, or it silently restricts every decorated function to the wrapper's fixed signature.
def repeat(times): # missing the middle 'decorator' layer def wrapper(func, *args, **kwargs): for _ in range(times): func(*args, **kwargs) return wrapper @repeat(3) def wave(): print('hi') def repeat(times): def decorator(func): def wrapper(*args, **kwargs): for _ in range(times): func(*args, **kwargs) return wrapper return decorator @repeat(3) def wave(): print('hi')
A decorator that takes its own arguments (@repeat(3)) needs three nested levels: repeat(times) returns decorator(func), which returns wrapper(*args, **kwargs) — collapsing them breaks the calling convention @decorator relies on.
What is @decorator above a function definition equivalent to, mechanically?
It is sugar for name = decorator(name): the function is defined normally, then immediately passed to decorator and the resulting object is bound back to the original name.
Why does a general-purpose decorator's wrapper typically use *args, **kwargs instead of naming specific parameters?
So the wrapper works for any wrapped function regardless of its signature — naming fixed parameters would force every decorated function to match that exact call shape.
What problem does functools.wraps solve, and what does it copy?
It solves the loss of the original function's identity when wrapped; it copies __name__, __doc__, __module__, __qualname__ from the wrapped function onto the wrapper and sets __wrapped__ to reference the original.
Why does @repeat(times=3) need an extra layer of nesting compared to a plain @loud?
Because @expr calls expr(func); for @repeat(times=3) to work, repeat(times=3) must itself evaluate to something callable with func — so repeat has to return the actual decorator function rather than being the decorator itself.
At what point does the outer body of a decorator (not the wrapper) execute?
Exactly once, when the decorated function's def statement is executed — typically at module import time — not each time the decorated function is later called.
How would you get access to the original, undecorated function after decoration, if the decorator used functools.wraps?
Via the wrapper's __wrapped__ attribute, which functools.wraps sets to point back at the original function.
Can a decorator return something other than a wrapper function, e.g. the original function unchanged, or even a completely different object?
Yes — a decorator can return anything, including the original function unmodified (useful for a no-op/conditional decorator) or a non-function object like a descriptor; @property is a real example that returns a property object, not a plain function.
Write a decorator debug that prints the function name and the arguments it was called with before calling it, preserving metadata with functools.wraps.
import functools def debug(func): @functools.wraps(func) def wrapper(*args, **kwargs): arg_strs = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()] print(f"{func.__name__}({', '.join(arg_strs)})") return func(*args, **kwargs) return wrapper @debug def add(a, b): return a + b add(2, b=3) # add(2, b=3) wrapper builds a readable representation of the call before delegating to func via *args, **kwargs, and functools.wraps keeps add.__name__ == 'add' rather than 'wrapper'.
What is printed, and in what order?
decorating calling hi calling hi 'decorating' prints once, when @outer runs at definition time; 'calling' and 'hi' print on each of the two hi() calls, since only wrapper's body runs per call.
Write a decorator factory retry(times) that calls the wrapped function up to times times, returning the first successful result and re-raising the last exception if all attempts fail.
import functools def retry(times: int): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last_exc = None for attempt in range(times): try: return func(*args, **kwargs) except Exception as exc: last_exc = exc raise last_exc return wrapper return decorator attempts = 0 @retry(times=3) def flaky(): global attempts attempts += 1 if attempts < 3: raise ValueError("not yet") return "ok" print(flaky()) # 'ok' after 3 attempts retry(3) returns decorator; decorator(flaky) returns wrapper, which loops up to 3 times catching exceptions, returning on the first success and re-raising the final exception if every attempt fails.
What does this print, and why?
15. @make_adder(10) calls make_adder(10), which returns 'add'; add(identity) returns wrapper; calling identity(5) now runs wrapper(5), which returns identity(5) + 10 = 5 + 10 = 15.
def outer(func):
print('decorating')
def wrapper(*a, **k):
print('calling')
return func(*a, **k)
return wrapper
@outer
def hi():
print('hi')
hi()
hi()def make_adder(n):
def add(func):
def wrapper(x):
return func(x) + n
return wrapper
return add
@make_adder(10)
def identity(x):
return x
print(identity(5))