Python Closures: Building a Counter Without a Global Variable
A closure remembers variables from the function that created it. Build separate counters with no globals, fix the UnboundLocalError with nonlocal, avoid the lambda-in-a-loop trap, and know when a class is clearer.
A closure is a function that remembers variables from the function that created it, even after that outer function has finished. The classic first use is a counter that doesn't need a global variable. It's a small example, but it shows the one rule that trips everyone up: reading an outer variable just works, and changing it needs nonlocal.
Skip ahead: If you know what a closure is and only hit UnboundLocalError, go to "Changing the remembered value". If you're deciding between a closure and a class, go to "Closure or class?".
The problem with a global counter
count = 0
def next_ticket():
global count
count += 1
return count
print(next_ticket(), next_ticket(), next_ticket())
count = 0
print(next_ticket())
1 2 3
1
It works, but count belongs to the whole file. Any code can reset it, like the count = 0 line above, and you can only have one counter. If you need one for the kitchen queue and one for the cashier queue, you're copying functions and globals.
A function that makes counters
def make_counter():
count = 0
def next_number():
nonlocal count
count += 1
return count
return next_number
kitchen = make_counter()
cashier = make_counter()
print(kitchen(), kitchen(), kitchen())
print(cashier())
print(kitchen())
1 2 3
1
4
make_counter() runs, creates count, defines next_number, and returns it. Normally count would be gone once make_counter finishes. But next_number still uses it, so Python keeps it alive, attached to that function. Each call to make_counter() creates a new count, so kitchen and cashier are fully separate. And no code outside can reach count to reset it by accident.
You can see where it's stored, if you're curious:
def make_counter():
count = 0
def next_number():
nonlocal count
count += 1
return count
return next_number
kitchen = make_counter()
kitchen()
kitchen()
print(kitchen.__code__.co_freevars)
print(kitchen.__closure__[0].cell_contents)
('count',)
2
Changing the remembered value
Remove the nonlocal line and run it:
def make_counter():
count = 0
def next_number():
count += 1
return count
return next_number
kitchen = make_counter()
kitchen()
Traceback (most recent call last):
File "/home/you/project/broken.py", line 12, in <module>
kitchen()
~~~~~~~^^
File "/home/you/project/broken.py", line 5, in next_number
count += 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Python decides whether a name is local when it reads the function, not when it runs it. count += 1 assigns to count, so Python treats count as a new local variable inside next_number. Then count + 1 tries to read that local before it has a value. The error says exactly that.
nonlocal count tells Python: this count is the one from the enclosing function, not a new local. Two rules:
- Reading an outer variable never needs
nonlocal. - Assigning to it (
=,+=) always does.
💡 Tip: Changing what's inside a list or dictionary isn't assigning to the variable. items.append(x) in an inner function works without nonlocal, because items still points to the same list. That's why older code (before Python 3 added nonlocal) used count = [0] and count[0] += 1. You don't need that trick anymore.
A more useful counter
Same idea, with a starting number and a step, plus a second function sharing the same remembered value:
def make_ticket_counter(prefix, start=1):
number = start - 1
def next_ticket():
nonlocal number
number += 1
return f"{prefix}-{number:03d}"
def peek():
return number
return next_ticket, peek
take, last = make_ticket_counter("K", start=41)
print(take())
print(take())
print("last issued:", last())
K-041
K-042
last issued: 42
next_ticket and peek were created in the same call, so they share the same number. peek only reads it, so it doesn't need nonlocal.
The loop trap
Closures remember the variable, not the value it had when the inner function was made:
buttons = []
for size in ["S", "M", "L"]:
buttons.append(lambda: f"Size {size}")
print([button() for button in buttons])
buttons = []
for size in ["S", "M", "L"]:
buttons.append(lambda size=size: f"Size {size}")
print([button() for button in buttons])
['Size L', 'Size L', 'Size L']
['Size S', 'Size M', 'Size L']
In the first loop, all three functions look up size when they're called, after the loop has finished, so they all see "L". The fix in the second loop uses a default argument, which is evaluated once, when each function is created. That's one of the few places where "defaults are created once" is exactly what you want.
Closure or class?
A closure is a small object with one method. When it grows, switch to a class:
class Counter:
def __init__(self, start=0):
self.count = start
def __call__(self):
self.count += 1
return self.count
def reset(self):
self.count = 0
kitchen = Counter()
print(kitchen(), kitchen())
kitchen.reset()
print(kitchen())
1 2
1
My rule: one action, maybe two, and nothing outside needs to see the state? Closure. Several actions, like reset, peek and history, or other code needs to read the value? Class. The class is a few lines longer and much easier for the next person to read once it grows. Neither one is more "advanced". They're two tools for the same job at different sizes.
Where you'll meet closures
- Decorators. The wrapper function remembers the function it wraps. That's a closure.
- Callbacks. A button handler that remembers which item it belongs to.
- Functions that make functions.
make_vat(0.12)returning a function that adds 12%.
Common mistakes
- **Forgetting
nonlocal.**UnboundLocalErrorthe first time you call it. - **Using
globalinstead.** It skips the enclosing function and goes straight to the module level. That's a different variable. - Lambdas in a loop. They all see the last value. Use a default argument.
- A closure with five inner functions. That's a class pretending not to be one.
What happens if you stick with globals
One counter works. The second one copies the first. Then a test resets the global and another test fails because it expected 3 and got 1. The bug isn't in either test. It's in the shared state both of them touched.
If two parts of your program both call next_ticket(), should they share one sequence of numbers or have their own, and does your code make that choice clear?
About
How closures keep state, why assignment needs nonlocal, two functions sharing one value, the late-binding loop trap, and closure vs class. After this, you can build small stateful functions and read decorator code more easily.
- Responsible
- Mary Q
- Last Update
- 09/12/2026
- Reading Time
- 6 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.