What is a decorator in Python, and how does it work under the hood?
A decorator is a callable that takes a function, wraps it with extra behaviour, and returns the new callable. The @syntax is syntactic sugar for reassigning the function name to the wrapper immediately after definition.
How to think about it
A decorator sounds mysterious until you see the one line it stands for. The @decorator syntax does nothing more than this: right after a function is defined, Python calls decorator(your_function) and rebinds the name to whatever comes back. So a decorator is simply a callable that takes a function and returns a (usually wrapped) function. The interviewer wants you to say that plainly — and then build one — rather than wave at “it adds functionality.”
Here is the equivalence in full:
@decorator
def greet(name): ...
# is exactly:
def greet(name): ...
greet = decorator(greet)
Python evaluates decorator(greet) the moment the def finishes and binds greet to the result. If the decorator returns a wrapper that closes over the original, every later call to greet now runs the wrapper instead.
A worked example
Let’s make a @trace decorator that announces each call and what it returned. The wrapper takes *args, **kwargs so it works on any function, and functools.wraps copies the original’s identity across so it isn’t lost:
import functools
def trace(func):
@functools.wraps(func) # keep func's __name__, __doc__, __wrapped__
def wrapper(*args, **kwargs):
print(f"-> calling {func.__name__}{args}")
result = func(*args, **kwargs)
print(f"<- {func.__name__} returned {result}")
return result
return wrapper
@trace
def add(a, b):
"""Add two numbers."""
return a + b
# @trace is exactly: add = trace(add)
print("result:", add(2, 3))
print("name preserved:", add.__name__) # 'add', not 'wrapper'
print("doc preserved :", add.__doc__)
# A decorator that takes its own arguments just needs one more layer:
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say(msg):
print(msg)
say("hello")
-> calling add(2, 3)
<- add returned 5
result: 5
name preserved: add
doc preserved : Add two numbers.
hello
hello
hello
Two things to notice. The wrapper ran around add — printing before and after — because add now points at wrapper. And add.__name__ still says add, not wrapper, only because of functools.wraps; drop it and the wrapper’s identity leaks through, breaking logging and introspection.
Stacking decorators
Stack them and they apply bottom-up — the one nearest the def wraps first:
@timer
@repeat(times=3)
def fetch_data(url): ...
# equivalent to: fetch_data = timer(repeat(times=3)(fetch_data))
The idea underneath
A decorator is just a higher-order function: a function that takes a function and returns a function, with @ as pure syntactic sugar for the rebind. Hold that one sentence and every decorator — class-based ones, ones that take arguments, stacked ones — becomes something you can reason out from first principles.