What a decorator actually is under the hood, why functions are objects, and how to write one that doesn't quietly break the function it wraps.
4 min read

In Python, a function is just an object. It can be assigned to a variable, passed as an argument, and returned from another function — the same as an integer or a string. Once that's true, a decorator stops being magic syntax and becomes something much simpler: a function that takes a function and returns a function.
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)
greet("ada") # "HELLO, ADA"That last line — greet = shout(greet) — is the entire mechanism. The @ syntax is only sugar for it:
@shout
def greet(name):
return f"hello, {name}"is exactly equivalent to greet = shout(greet). Nothing else is happening. Once this clicks, every decorator you'll ever read is legible: find the function being passed in, find what gets returned, and you know what actually runs when the decorated function is called.
import time
def timed(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_query():
"""Runs the expensive report query."""
time.sleep(0.2)
print(slow_query.__name__) # 'wrapper' — wrong
print(slow_query.__doc__) # None — wrongThe decorated function's identity got quietly replaced. Anything that introspects the function — debuggers, API doc generators, help(), test frameworks that print function names in failure output — now sees wrapper, not slow_query. This is exactly the kind of bug that doesn't crash anything, so it survives in real codebases for years.
The fix is functools.wraps, applied to the inner function:
import functools
import time
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_query():
"""Runs the expensive report query."""
time.sleep(0.2)
print(slow_query.__name__) # 'slow_query' — correct
print(slow_query.__doc__) # 'Runs the expensive report query.' — correctfunctools.wraps copies __name__, __doc__, and a few other metadata attributes from the original function onto the wrapper. The rule worth internalizing: any decorator that defines an inner function should decorate that inner function with functools.wraps(func). There's essentially no cost to including it and a real, if quiet, cost to leaving it out.
@timed above takes no arguments — it's applied directly. Something like @retry(times=3) needs an extra layer, because retry(times=3) has to itself return a decorator:
import functools
import time
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
raise last_error
return wrapper
return decorator
@retry(times=3)
def flaky_api_call():
...Three nested functions, three jobs:
retry(times) — takes the decorator's own arguments, returns the real decorator.decorator(func) — takes the function being decorated, returns the wrapper. This is the part that's a normal decorator, same shape as timed above.wrapper(*args, **kwargs) — the thing that actually runs on every call.@staticmethod — the method receives neither self nor cls. It's a plain function that happens to live in the class's namespace, used when the logic genuinely doesn't need instance or class state.@classmethod — the method receives cls (the class itself) instead of self. The most common real use is an alternative constructor: Point.from_tuple((3, 4)) calling cls(x, y) internally.@property — turns a method into something accessed like an attribute (obj.value instead of obj.value()), letting you add validation or computed logic behind what looks like a plain field.@functools.lru_cache — memoizes a function's return value by its arguments, so repeated calls with the same inputs skip recomputation. Useful for pure functions with expensive, repeatable work; dangerous on functions with side effects or unhashable arguments.functools.wrapsstaticmethod, classmethod, property)@ syntax.Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What is `@shout` above `def greet(name):` actually equivalent to?
2. Without functools.wraps, what happens to a decorated function's __name__?
3. Why does a decorator that takes its own arguments, like @retry(times=3), need three levels of nested functions instead of one?
4. What does @property let you do that a plain method doesn't?
Python