Closures and decorators
Functions that remember, wrap and extend other functions.
Create a free account Try part of it here
Why it matters
Decorators are the @ lines above functions in almost every Python framework: @app.route in Flask, @pytest.fixture, @property, @functools.cache. They add behavior such as logging, timing, access checks or caching to a function without changing its code. Understanding them turns those @ lines from magic into something you can read, debug and write yourself. The secret is closures, so that's where we start.
What you'll be able to do
- Explain closures: inner functions that remember variables from where they were made
- Avoid the late-binding trap when making functions in a loop
- Write decorators, keep their metadata with functools.wraps, and give them arguments
- Read stacked decorators in the right order, and cache results with functools.cache
What it covers
- Closures: functions that remember
- The late-binding trap
- A decorator is a function that wraps a function
- Decorators with arguments
- Retry a flaky call
- Stacking, and caching
- Decorators you'll meet
- Worked example: a timing decorator you can test
Closures: functions that remember
Functions are values (the functions-as-values lesson showed this), so a function can build and return another function. The inner function keeps access to the variables of the function that made it, even after that function has returned. That's a closure:
def make_discount(percent): def apply(price): return round(price * (1 - percent / 100), 2) return apply member_price = make_discount(10) clearance = make_discount(40) print(member_price(200), clearance(200)) print(member_price.__closure__[0].cell_contents) def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment ticket = make_counter() print(ticket(), ticket(), ticket())
Each call to make_discount makes a new apply with its own percent. To change a remembered variable, declare it nonlocal. Without that line, count += 1 would make a new local variable and fail, because it reads count before assigning it.
The late-binding trap
A closure remembers the variable, not its value at the time. Make functions in a loop and they all see the variable's final value:
handlers = [] for size in ["S", "M", "L"]: handlers.append(lambda: f"size {size}") print([h() for h in handlers]) fixed = [] for size in ["S", "M", "L"]: fixed.append(lambda size=size: f"size {size}") print([h() for h in fixed])
All three lambdas read size when they're called, after the loop has finished, so they all say "L". The fix is a default argument, size=size. Defaults are evaluated when the function is made, so each lambda gets its own copy, frozen at the value size had on that pass through the loop. Another fix is a small factory function like make_discount. This bug turns up surprisingly often in GUI callbacks and in lists of functions built in loops.
fs = [lambda x: x * n for n in range(3)] print([f(10) for f in fs])
Show the answer
[20, 20, 20]
Every lambda looks up n when it's called, after the comprehension finished with n = 2. So each returns 10 × 2.
A decorator is a function that wraps a function
A decorator takes a function and returns a new function that adds something before or after calling the original. The @name line is shorthand: @log_calls above def add means add = log_calls(add).
import functools def log_calls(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"calling {func.__name__}{args}") result = func(*args, **kwargs) print(f"{func.__name__} returned {result!r}") return result return wrapper @log_calls def shipping(weight_kg, express=False): """Shipping cost in dollars.""" return 15 + 2 * weight_kg if express else 6 + weight_kg shipping(3) shipping(3, express=True) print(shipping.__name__, "|", shipping.__doc__)
wrapper accepts any arguments with *args, **kwargs and passes them through, so the decorator works on any function. functools.wraps(func) copies the original's name, docstring and other details onto the wrapper. Without it, shipping.__name__ would say "wrapper", which confuses error messages, help() and any tool that looks functions up by name.
Notice that "decorating greet" prints when the def runs, before any call. The later call goes to wrapper, which calls the original greet inside.
Decorators with arguments
@app.route("/deals") passes an argument to the decorator. That takes one more layer: a function that takes the arguments and returns a decorator.
import functools def require_role(role): def decorator(func): @functools.wraps(func) def wrapper(user, *args, **kwargs): if role not in user.get("roles", []): raise PermissionError(f"{user['name']} needs the {role!r} role for {func.__name__}") return func(user, *args, **kwargs) return wrapper return decorator @require_role("admin") def delete_product(user, sku): return f"{sku} deleted by {user['name']}" print(delete_product({"name": "Ada", "roles": ["admin"]}, "TE-106")) try: delete_product({"name": "Ben", "roles": ["staff"]}, "TE-106") except PermissionError as e: print("refused:", e)
Read it from the outside in, one layer per call: require_role("admin") runs first and returns decorator; then decorator receives delete_product and returns wrapper. Three nested functions look intimidating, but each layer has one job: hold the argument, hold the function, run the check.
Retry a flaky call
Write retry(times), a decorator with an argument: the decorated function is called up to times times while it raises ConnectionError; any other exception, or the last failure, goes straight to the caller. Keep the function's name with functools.wraps.
import functools def retry(times): pass attempts = [] @retry(3) def fetch_prices(): attempts.append(1) if len(attempts) < 3: raise ConnectionError("network blip") return {"TE-101": 200} print(fetch_prices(), len(attempts), fetch_prices.__name__)
Stacking, and caching
Several decorators can sit on one function. They apply from the bottom up: the one nearest the def wraps the function first, and the top one wraps the result. When the function is called, they run from the top down, like layers of an onion.
@log_calls # 2nd: wraps the checked function, so it logs even refused calls @require_role("admin") # 1st: wraps delete_product def delete_product(user, sku): ...
The standard library has decorators you'll use constantly. functools.cache remembers every result by its arguments, so repeated calls are instant; functools.lru_cache(maxsize=...) keeps only the most recent ones:
import functools @functools.cache def ways_to_climb(steps): if steps <= 1: return 1 return ways_to_climb(steps - 1) + ways_to_climb(steps - 2) print(ways_to_climb(80)) print(ways_to_climb.cache_info())
Without the cache, this recursion would make about 10^16 calls; with it, 81. Caching is only safe for pure functions, whose results depend on nothing but their arguments. Never cache something that reads the clock, a file or a database.
Decorators you'll meet
Most decorators you use will come from libraries. Knowing how they work tells you what they do:
@app.route("/deals")in Flask and@app.get("/deals")in FastAPI take arguments, so they're the three-layer kind. Their main job is to register your function in a table of URLs, then return it.@pytest.fixtureand@pytest.mark.parametrize(...)mark functions so pytest knows how to call them.@property,@classmethodand@staticmethodchange how a method is looked up and called.@functools.cacheand@functools.lru_cacheremember results.@dataclassdecorates a class: it reads the class's fields and writes methods into it.
Decorators work on methods too. The wrapper's *args simply receives self as its first argument and passes it along, so @log_calls above a method needs no changes. And because a decorator only has to be something callable that returns a function, a class with a __call__ method can be one as well, which is handy when the decorator needs to keep a lot of state.
A decorator runs once, when the def is executed, which is usually at import time. That's why a route decorator can register URLs before any request arrives. It's also why a decorator that does slow work when it runs slows down every import of that module. Keep that first step light, and do the real work in the wrapper.
When a decorated function misbehaves, remember you're calling the wrapper. Its __wrapped__ attribute (added by functools.wraps) gives you the original to test on its own, without logging, caching or access checks getting in the way.
Worked example: a timing decorator you can test
Measure how long calls take, and keep the measurements. The clock is passed in, so a test can control time instead of waiting for it.
import functools import time def timed(clock=time.perf_counter, store=None): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = clock() try: return func(*args, **kwargs) finally: elapsed = clock() - start if store is not None: store.setdefault(func.__name__, []).append(elapsed) return wrapper return decorator timings = {} fake_now = iter([0.0, 1.5, 10.0, 10.2]) @timed(clock=lambda: next(fake_now), store=timings) def build_report(rows): return len(rows) build_report([1, 2, 3]) build_report([]) print(timings) @timed(store=timings) def real_work(): return sum(range(100_000)) real_work() print(sorted(timings), timings["real_work"][0] > 0)
timed takes options, so it has the three layers. The finally records the time even when the function raises. Passing the clock in means a test can feed it fake times and check exact numbers, the "pass now in" idea from the dates lesson. Real code just uses the default. store.setdefault(name, []) creates each function's list the first time it's timed.
Practice what you just read
- 7 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does @functools.wraps(func) on a wrapper do?