Closures
Overview
A closure is a function that remembers variables from the scope where it was created, even after that outer scope has finished running. Think of it as a backpack the inner function carries: when the outer function returns, the inner function keeps the variables it packed. The inner function keeps a persistent reference to its enclosing scope's variables across calls, even after the outer function has returned. def make_greeter(greeting): def greeter(name): return f"{greeting}, {name}!" return greeter say_hello = make_greeter("Hello") print(say_hello("Alice")) Closures capture the variable itself, not the value at creation time. This leads to the late binding closure trap in loops. # Late binding closure trap funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # Outputs [2, 2, 2] When building decorators, closures can accidentally hide the original function's identity. def log(fn): return lambda *args: fn(*args) @log def add(a, b): pass print(add.__name__) # Outputs '<lambda>', not 'add' This is the lost metadata trap. It is covered fully in decorators.
Closures let you build functions with private, persistent state without a class. They are the mechanism that makes decorators possible. Any time you configure a function by wrapping it, a closure is doing the remembering.
Where used: FastAPI, Pydantic validators, micrograd, functools
Why learn this
- Unlocks decorators, which power every `FastAPI` route, dependency, and middleware.
- Lets you write configurable function factories (e.g. a parametrised logger).
- Explains why `micrograd`'s backward functions can 'see' the values from their forward pass.
Code walkthrough
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c = make_counter()
print(c())
print(c())
print(c())
Focus: Watch `count` survive between calls - it lives in the closure, not a global.
Common mistakes
- Late binding in loops: Closures capture the variable, not its value at creation time. This means `[lambda: i for i in range(3)]` will return `2` for every function. To fix this, bind the value explicitly with a default argument: `lambda i=i: i`.
- Assuming you can reassign the outer variable: Reading an enclosing variable works automatically, but assigning to it makes Python treat it as a local variable. If you try to modify the outer variable, you will get an `UnboundLocalError`. Use the `nonlocal` keyword to explicitly rebind the captured variable.
- Confusing closures with global state: Each call to the outer function creates a fresh, independent closure. Two counters generated from the same factory do not share their `count` state.
Glossary
- scope
- The specific area in code where a variable is recognized and can be used, e.g. `def f(): x=1` means `x` is in local scope.
- decorator
- A special function that wraps another function to modify or enhance its behavior without changing its code, e.g. `@app.get('/')`.
Recall questions
- In one sentence, what does a closure 'remember'?
- Why does building a decorator require a closure?
- What problem do closures solve that you would otherwise reach for a class to do?
- What happens to a function's name and docstring when it is wrapped by a closure without using `functools.wraps`?
Understanding checks
What will the following code output?
30
The `multiplier` inner function captures the variable `n` from the enclosing scope. When `times3` is called with `10`, it multiplies `10` by the captured value `3`.
Why do you need the `nonlocal` keyword when modifying a captured variable inside a closure?
Without `nonlocal`, assigning a value to a variable inside the inner function creates a new local variable, rather than updating the variable in the enclosing scope.
Python's scoping rules dictate that any assignment makes a variable local to the current function. The `nonlocal` keyword explicitly tells Python to rebind the variable in the nearest enclosing scope.
What will the following code output?
[1, 1]
Closures capture the variable `i`, not its value at the time the function is created. By the time the functions are called, the loop has finished and `i` is `1`.
Practice tasks
Closure counter
The `make_counter` function currently relies on a global variable. Modify it to use a closure by moving the `count` variable inside `make_counter` and using `nonlocal` inside the inner function.
Challenge
Implement a logging decorator
Using a closure, write a `@log_calls` decorator that prints the function name and arguments before calling it, then prints the return value. Preserve the wrapped function's name with `functools.wraps`.
Continue learning
Previous: Defining functions and return values
Previous: LEGB rule: variable scope in Python
Previous: First-class functions: passing and returning functions
Next: Function Decorators