Decorators
Topic 2 of 7, with 3 concept checks. Wrapping functions to add behavior without changing their code
Wrap behavior without rewriting the function
Callable wrappers
Trace how a decorator receives a callable, returns a replacement, and preserves the wrapped contract so logging, validation, or caching does not obscure the original API.
Core lesson 01
@decorator above a function is syntactic sugar for func = decorator(func) — the decorator receives the original function and returns a replacement bound to the same name.
A decorator is any callable that takes a function and returns a callable. @my_decorator on top of def f(): ... runs immediately at definition time, replacing f with my_decorator(f). This is how you transparently add logging, timing, caching, or access control around a function without touching its internal code.
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello, {name}"
print(greet("ada")) # HELLO, ADA
# equivalent to: greet = shout(greet)What to remember
What is a decorator, and what does @decorator actually do?
Common footguns
- Forgetting the wrapper needs *args, **kwargs to forward arbitrary arguments — otherwise the decorated function loses its original signature's flexibility.
- Decorating a function and being confused why help(greet) shows wrapper's info instead of greet's — see functools.wraps below.
Core lesson 02
functools.wraps(func) copies the original function's __name__, __doc__, and metadata onto the wrapper, so introspection tools still see the original function's identity.
Without @functools.wraps(func) on the inner wrapper, the decorated function's __name__ becomes 'wrapper', its docstring disappears, and tools relying on function metadata (logging, doc generators, some test frameworks) get confused about what's being called. functools.wraps is itself a decorator that fixes this by copying over the important metadata attributes.
import functools
def shout(func):
@functools.wraps(func)
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', not 'wrapper'
print(greet.__doc__) # 'Return a greeting.'What to remember
Why do decorators typically need functools.wraps?
Common footguns
- Skipping functools.wraps 'because it still works' — it works functionally but breaks introspection, stacking multiple decorators, and anything relying on __name__ (like some caching or routing decorators).
Core lesson 03
Add an outer function that takes the decorator's own arguments and returns the real decorator — three nested levels total: factory(args) -> decorator(func) -> wrapper(*a, **kw).
A plain decorator takes exactly one argument: the function being decorated. To support @retry(times=3), you need a factory function that accepts times and returns a decorator, which itself returns a wrapper. It looks like three levels of nested functions because it is — each level closes over the one before it.
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"attempt {attempt} failed: {e}")
raise
return wrapper
return decorator
@retry(times=3)
def flaky():
raise ValueError("nope")
try:
flaky()
except ValueError:
print("gave up after 3 tries")What to remember
How do you write a decorator that accepts its own arguments (a decorator factory)?
Common footguns
- Forgetting the extra layer and writing @retry without () — that passes the function itself as `times`, causing confusing errors deep inside the decorator.
retry(times=3) -- factory call, returns `decorator`
-> decorator(flaky) -- decorator call, returns `wrapper`
-> wrapper(*a,**kw) -- what actually runs when you call flaky()Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Always apply functools.wraps to preserve the original function's metadata.
- Keep decorators narrowly focused — one decorator, one responsibility.
- Order matters when stacking multiple decorators — they apply bottom-up, closest to the function first.
- Prefer functools.lru_cache over writing your own memoization decorator for simple cases.
Apply the concept in Interview practice
LRU CachemediumLeetCode #146 · O(1) get/put
Combine a dict with a doubly linked list (or OrderedDict) so both get and put are O(1); move accessed items to the 'most recent' end and evict from the 'least recent' end when over capacity.
Open problemTime Based Key-Value StoremediumLeetCode #981 · O(log n) get
Store a list of (timestamp, value) pairs per key and binary search for the largest timestamp <= the query timestamp.
Open problemNumber of Recent CallseasyLeetCode #933 · O(1) amortized per call
Keep a queue of call timestamps; on each ping, pop timestamps older than 3000ms from the front, then return the queue's length — the same rate-limiting pattern often built as a decorator.
Open problemConcept checks
What is a decorator, and what does @decorator actually do?
Hint
It's just function application in disguise.
@deco above def f(): ... is shorthand for f = deco(f).
Answer
@decorator above a function is syntactic sugar for func = decorator(func) — the decorator receives the original function and returns a replacement bound to the same name.
A decorator is any callable that takes a function and returns a callable. @my_decorator on top of def f(): ... runs immediately at definition time, replacing f with my_decorator(f). This is how you transparently add logging, timing, caching, or access control around a function without touching its internal code.
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello, {name}"
print(greet("ada")) # HELLO, ADA
# equivalent to: greet = shout(greet)Watch out
- Forgetting the wrapper needs *args, **kwargs to forward arbitrary arguments — otherwise the decorated function loses its original signature's flexibility.
- Decorating a function and being confused why help(greet) shows wrapper's info instead of greet's — see functools.wraps below.
Why do decorators typically need functools.wraps?
Hint
Without it, the decorated function loses its identity.
Its __name__, __doc__, and other metadata get replaced by the wrapper's.
Answer
functools.wraps(func) copies the original function's __name__, __doc__, and metadata onto the wrapper, so introspection tools still see the original function's identity.
Without @functools.wraps(func) on the inner wrapper, the decorated function's __name__ becomes 'wrapper', its docstring disappears, and tools relying on function metadata (logging, doc generators, some test frameworks) get confused about what's being called. functools.wraps is itself a decorator that fixes this by copying over the important metadata attributes.
import functools
def shout(func):
@functools.wraps(func)
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', not 'wrapper'
print(greet.__doc__) # 'Return a greeting.'Watch out
- Skipping functools.wraps 'because it still works' — it works functionally but breaks introspection, stacking multiple decorators, and anything relying on __name__ (like some caching or routing decorators).
How do you write a decorator that accepts its own arguments (a decorator factory)?
Hint
You need one more layer of function nesting.
The outermost function takes your arguments and returns the actual decorator.
Answer
Add an outer function that takes the decorator's own arguments and returns the real decorator — three nested levels total: factory(args) -> decorator(func) -> wrapper(*a, **kw).
A plain decorator takes exactly one argument: the function being decorated. To support @retry(times=3), you need a factory function that accepts times and returns a decorator, which itself returns a wrapper. It looks like three levels of nested functions because it is — each level closes over the one before it.
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"attempt {attempt} failed: {e}")
raise
return wrapper
return decorator
@retry(times=3)
def flaky():
raise ValueError("nope")
try:
flaky()
except ValueError:
print("gave up after 3 tries")retry(times=3) -- factory call, returns `decorator`
-> decorator(flaky) -- decorator call, returns `wrapper`
-> wrapper(*a,**kw) -- what actually runs when you call flaky()Watch out
- Forgetting the extra layer and writing @retry without () — that passes the function itself as `times`, causing confusing errors deep inside the decorator.