Functions that wrap functions
Closures remember state, decorators add behaviour around calls, and functools supplies production-ready helpers. Interviewers expect you to write a decorator from scratch.
- ClosuresCaptured variables and factories.
- DecoratorsSimple, parameterised and stacked.
- functoolswraps, lru_cache, partial and reduce.
Decorators appear in almost every framework — Flask routes, pytest fixtures, Django views, FastAPI endpoints — and “write a decorator” is a favourite live-coding task. Build them up from closures and they become simple.
1. What is a closure?
A closure is an inner function that remembers variables from the enclosing function's scope after that function has returned. Python stores those variables in the function's __closure__ cells. Closures are the building block of decorators and function factories.
def make_multiplier(factor):
def multiply(x):
return x * factor # factor is remembered
return multiply
double, triple = make_multiplier(2), make_multiplier(3)
print(double(5), triple(5)) # 10 152. Write a decorator that logs calls and explain each line.
A decorator takes a function, defines a wrapper that adds behaviour before and after calling it, and returns the wrapper. @log_calls above a function is shorthand for func = log_calls(func). Accept *args, **kwargs so it works for any signature, and use functools.wraps to keep the original name and docstring.
import functools, logging
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info("calling %s args=%s kwargs=%s", func.__name__, args, kwargs)
result = func(*args, **kwargs)
logging.info("%s returned %r", func.__name__, result)
return result
return wrapper
@log_calls
def add(a, b):
return a + b3. How do you write a decorator that takes arguments?
Add one more level: a function that takes the arguments and returns the actual decorator. A retry decorator is the classic example.
import functools, time
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
time.sleep(delay * attempt) # simple back-off
return wrapper
return decorator
@retry(times=5, exceptions=(ConnectionError,))
def fetch_rates():
...4. In what order are stacked decorators applied?
Bottom-up when decorating, top-down when called. @a above @b means func = a(b(func)): b wraps first, and at call time a's wrapper runs first.
@timed # applied second, runs first
@log_calls # applied first, runs second
def build_report():
...
# equivalent to: build_report = timed(log_calls(build_report))5. How does functools.lru_cache work and when is it unsafe?
It memoises results keyed by the arguments, so repeated calls with the same inputs return instantly. Arguments must be hashable. Do not cache functions with side effects or results that change over time (current prices, database reads) unless stale data is acceptable; use maxsize to bound memory.
from functools import lru_cache
@lru_cache(maxsize=1024)
def shipping_cost(pincode: str, weight_kg: int) -> float:
... # expensive calculation
shipping_cost.cache_info() # hits, misses, size
shipping_cost.cache_clear()6. Explain map, filter and reduce. Are they Pythonic?
map applies a function to each item, filter keeps items where it returns true, and functools.reduce folds a sequence into one value. They are lazy (map and filter return iterators). For simple cases comprehensions are usually more readable; built-ins such as sum, max and any often replace reduce.
from functools import reduce
prices = [120, 80, 250]
with_tax = list(map(lambda p: p * 1.18, prices))
expensive = list(filter(lambda p: p > 100, prices))
total = reduce(lambda a, b: a + b, prices) # but sum(prices) is clearer
with_tax = [p * 1.18 for p in prices] # usually preferred7. What does functools.partial do?
It creates a new function with some arguments pre-filled — useful for callbacks and adapting functions to an expected signature.
from functools import partial
def send(channel, to, text):
print(f"[{channel}] {to}: {text}")
send_sms = partial(send, "sms")
send_sms("Ravi", "Your OTP is 4821")8. Give real-world uses of decorators.
Authentication and permission checks on web views, timing and logging, caching, retries, input validation, registering routes or plugins (@app.get("/users")), rate limiting, and transaction management. Class decorators such as @dataclass modify classes the same way.
ROUTES = {}
def route(path):
def register(func):
ROUTES[path] = func # registration decorator
return func
return register
@route("/health")
def health():
return "ok"Remember
Key points
- @d means f = d(f)
Decoration happens at definition time.
- Always use functools.wraps
Keeps names, docstrings and introspection.
- Arguments need an extra layer
Factory → decorator → wrapper.
- Cache only pure functions
Hashable arguments, no side effects.
Interview drill
Follow-up questions
What the interviewer asks after your first answer. Turn on practice mode, answer each one aloud, then reveal.
-
1 Why does a decorated function lose its name without wraps? Basic
Because the decorator returns the wrapper function, whose
__name__is “wrapper”.functools.wrapscopies the original metadata onto it. -
2 Can a class be used as a decorator? Intermediate
Yes — a class with
__call__can wrap a function and keep state (such as a call counter) in attributes. -
3 How do you decorate an async function? Advanced
The wrapper must be
async defandawaitthe original function; otherwise it returns an un-awaited coroutine.def timed_async(func): @functools.wraps(func) async def wrapper(*args, **kwargs): start = time.perf_counter() try: return await func(*args, **kwargs) finally: print(func.__name__, time.perf_counter() - start) return wrapper -
4 What is the late-binding closure trap in loops? Intermediate
[lambda: i for i in range(3)]all return 2, because each lambda looks upiwhen called. Bind the value with a default argument:lambda i=i: i. -
5 What is the difference between cache and lru_cache? Basic
functools.cacheis an unboundedlru_cache(maxsize=None)— simpler and slightly faster, but it never evicts. -
6 What is a pure function and why does it matter? Basic
Its output depends only on its inputs and it has no side effects. Pure functions are easy to test, cache and run in parallel.