Functions, Closures & Decorators
First-class functions, closures, and decorators — the machinery behind caching, retries, and most framework 'magic'.
Python functions are ordinary objects. You can pass them to other functions, return them, store them on instances, and wrap them. That one fact explains callbacks, decorators, dependency injection, retries, caching, and much of the “magic” in Python frameworks.
The sharp edge is scope. Closures remember variables, not frozen values, and function defaults are created once. Learn those two rules and decorators become a small, predictable tool instead of ceremony.
The mental model
A function definition creates a function object and binds a name to it. Calling a function executes its body. Passing a function moves the object around without calling it.
def normalize(text: str) -> str:
return " ".join(text.lower().split())
def apply_all(value: str, funcs):
for func in funcs:
value = func(value)
return value
clean = apply_all(" Hello WORLD ", [normalize, str.strip])
A closure is a function that uses variables from an outer scope after that outer function has returned.
def make_prefixer(prefix: str):
def add_prefix(text: str) -> str:
return f"{prefix}{text}"
return add_prefix
warn = make_prefixer("WARN: ")
warn("slow request")
The inner function keeps a reference to prefix. That is powerful, but it means you need to understand what value is being referenced.
First-class functions make behavior injectable
Instead of hard-coding a behavior, accept a function. That keeps code testable and keeps policy separate from mechanics.
def retry_if(operation, should_retry, attempts: int):
for attempt in range(1, attempts + 1):
try:
return operation()
except Exception as exc:
if attempt == attempts or not should_retry(exc):
raise
Production code uses this constantly: sort keys, validation callbacks, serializer functions, dependency injection, and mockable boundaries in tests.
Closures and late binding
Closures capture names, not snapshots. The classic bug appears when creating functions in a loop:
funcs = []
for i in range(3):
funcs.append(lambda: i)
[f() for f in funcs] # [2, 2, 2]
Each lambda refers to the same i, and after the loop ends i is 2. Bind the current value as a default argument or use a helper function.
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
[f() for f in funcs] # [0, 1, 2]
Default arguments are evaluated when the function is defined, which is useful here and dangerous with mutable defaults later.
*args and **kwargs
*args captures extra positional arguments as a tuple. **kwargs captures extra keyword arguments as a dict. Decorators use both so they can wrap functions with any signature.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Use them to forward arguments, not to hide sloppy APIs. If you own the function, a precise signature is usually better.
Decorators are function transformers
A decorator is just syntax for rebinding a function to the result of another function.
@decorator
def work():
...
# means:
def work():
...
work = decorator(work)
Always use functools.wraps in the wrapper. It preserves the original function’s name, docstring, annotations, and enough metadata for debugging and test tools.
from functools import wraps
def traced(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"start {func.__name__}")
return func(*args, **kwargs)
return wrapper
Decorators with arguments add one more layer: a function that receives the configuration and returns the real decorator.
result = fetch(url)Timing, retry, and cache decorators
These three patterns cover most practical decorators:
- Timing — measure elapsed time around the call.
- Retry — catch selected transient exceptions and try again.
- Cache — remember results for pure functions; prefer
functools.cacheorfunctools.lru_cachebefore writing your own.
from functools import lru_cache
@lru_cache(maxsize=1024)
def tokenize(text: str) -> tuple[str, ...]:
return tuple(text.lower().split())
The hidden contract is purity. Caching a function that depends on time, random state, environment variables, or a database can preserve the wrong answer.
When to reach for this
Variations
Try it
These exercises focus on the contracts: preserve return values, preserve metadata, and do not let hidden state leak between calls.
Write a @timed decorator
Create a decorator that prints how long a function took and returns the original result unchanged. It should work with any positional or keyword arguments.
Approach. Wrap the call in perf_counter(), forward *args and **kwargs, and use functools.wraps so the wrapped function still looks like itself.
Show solution
from functools import wraps
from time import perf_counter
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
started_at = perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = perf_counter() - started_at
print(f"{func.__name__} took {elapsed:.3f}s")
return wrapper
@timed
def parse_batch(path: str) -> int:
return load_and_parse(path)The finally block logs even when the function raises, but it does not swallow the exception. The caller still gets the original result or the original failure.
Write a parametrized @retry(n) decorator
Implement @retry(tries=3) for transient failures. It should retry exceptions up to tries total attempts and re-raise the last exception if every attempt fails.
Approach. A decorator with arguments has three layers: the outer function receives configuration, the middle receives func, and the wrapper receives the call arguments.
Show solution
from functools import wraps
from time import sleep
def retry(tries: int, delay: float = 0.0, exceptions=(Exception,)):
if tries < 1:
raise ValueError("tries must be at least 1")
def decorate(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, tries + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == tries:
raise
if delay:
sleep(delay)
return wrapper
return decorate
@retry(tries=3, delay=0.2, exceptions=(TimeoutError,))
def fetch_embedding(text: str) -> list[float]:
return call_embedding_api(text)exceptions is configurable so you do not retry programming errors by accident. In real services, add jitter and a maximum delay; the core shape stays the same.
Fix the mutable default argument
What does this print, and how do you make each call independent?
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
print(add_tag("ai"))
print(add_tag("ml"))Approach. Default values are evaluated once, when the function is defined. The same list is reused on every call. Use None as a sentinel and create a new list inside the function.
Show solution
def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag("ai")) # ["ai"]
print(add_tag("ml")) # ["ml"]
print(add_tag("cv", ["ml"])) # ["ml", "cv"]The original version prints ['ai'] and then ['ai', 'ml'] because both calls share one default list. Immutable defaults like None, 0, or "" are fine; mutable defaults are almost always a bug.