Stateful Functions Using Closures
Overview
A stateful closure is a function that remembers and updates data between calls, without using global variables or a full class. It does this by using the `nonlocal` keyword to modify variables trapped in its enclosing scope. Think of it like a function carrying around a hidden, persistent backpack of data. This allows you to encapsulate state perfectly within a single callable. It is commonly used to build lightweight state machines or function factories. When applying stateful closures to wrap multiple functions, reusing the same factory instance accidentally links their internal state. This shared-state trap is covered fully in decorators. shared_limiter = make_rate_limiter() @shared_limiter def fetch(): pass @shared_limiter def post(): pass # Trap: fetch and post now share the exact same call count!
It provides a lightweight alternative to writing a `class` when you only need a single function that remembers some history, like a counter or an accumulator. It keeps state strictly private and avoids polluting the global namespace.
Where used: FastAPI dependencies, Rate limiters, Event callbacks
Why learn this
- Replacing simple single-method classes with clean, functional code
- Building lightweight function factories that maintain isolated state
Code walkthrough
def make_accumulator():
total = 0
def add(value):
nonlocal total
total += value
return total
return add
acc1 = make_accumulator()
print(acc1(5))
print(acc1(10))
Focus: nonlocal total
Aha moment
def make_counter():
count = 0
def inc():
nonlocal count
count += 1
return count
return inc
c1 = make_counter()
c2 = make_counter()
c1()
print(c2())
Prediction: What does `print(c2())` output?
Common guess: 2
Each time `make_counter()` is called, it creates a brand new enclosing scope with its own separate `count` variable. `c1` and `c2` have completely independent backpacks of state, so `c2()` returns `1` regardless of what `c1` does.
Common mistakes
- Forgetting the nonlocal keyword: If you try to read and update an enclosed variable, Python marks it as a local variable, causing an `UnboundLocalError`. You must explicitly declare `nonlocal var_name` to modify the trapped state.
- Accidental shared state: If multiple functions close over the exact same variable and modify it, they will interfere with each other's state. You must create a new closure instance by calling the factory function again for independent state. This is especially common when wrapping functions with stateful decorators.
Glossary
- encapsulate
- To bundle data and the methods that operate on that data into a single, enclosed unit, keeping it safe from outside interference. Example: `counter = make_counter()`.
- functional code
- A style of programming that emphasizes using functions as the primary building blocks, avoiding shared state or side effects. Example: `total = sum(map(double, numbers))`.
Recall questions
- How does a closure maintain state between function calls without using global variables?
- Why might you use a stateful closure instead of creating a new class?
- What happens if you use the exact same stateful closure instance to wrap multiple different functions?
Understanding checks
What is printed by this code?
`2`, `4`, `2`
Each call to `setup()` creates an independent closure state. `f1` increments its own `x` twice (`2`, `4`), while `f2` uses a fresh `x` (starting at `0`, becoming `2`).
Why does this closure raise an `UnboundLocalError`?
It is missing the `nonlocal balance` declaration.
Without `nonlocal`, Python treats the assignment `balance += amount` as creating a new local variable `balance` inside `add`, which hasn't been initialized yet.
Practice tasks
Build a Simple Counter
Write a function `make_counter()` that takes no arguments. It should return a function that, when called, returns an integer starting at `1` and incrementing by `1` on each subsequent call.
Challenge
Moving Average Closure
Create a function `make_averager()` that returns a function. The returned function should take a single numeric value as an argument, add it to a running series, and return the average (mean) of all values passed to it so far. Hint: You can use a mutable `list` in the closure without `nonlocal`, or use `nonlocal` with `sum` and `count` variables.
Continue learning
Previous: Closures
Previous: LEGB rule: variable scope in Python
Next: Function Decorators