Python says my counter has no value
See the problem
Section titled “See the problem”You’re numbering orders. Each call should give the next number, so you keep the last one in count:
count = 0
def next_id(): count += 1 return count
print(next_id())count is right there, set to 0. Yet Python stops:
UnboundLocalError: cannot access local variable 'count' where it is not associated with a valueEven more surprising, reading it works: def show(): return count gives 0. Only adding to it fails. Why?
Define the task
Section titled “Define the task”Two parts of your shop need numbers: orders and invoices. So instead of one counter, write a function that makes counters.
- Input:
make_counter(start=0)takes the number to count up from. - Output: a function, like
orders. Each call of it gives back the next number:start + 1, thenstart + 2, and so on. - The hard part: each counter keeps its own count. Calling
orders()never movesinvoices, and making a new counter never resets an old one.
Example: orders = make_counter() gives orders(), orders(), orders() as 1, 2, 3. Then invoices = make_counter(100) gives 101 on its first call, and orders() continues with 4.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
count += 1 means count = count + 1. It reads count, and it also sets count.
Hint 2 of 3
Any name a function sets is local to that function, from its first line, unless you say otherwise.
Hint 3 of 3
Keep count inside make_counter, so each counter gets its own. Then tell tick that count belongs to make_counter.
Understand the idea
Section titled “Understand the idea”Python keeps names in boxes, called scopes. There’s a box for the whole file (the global box). Each call of a function gets a new box of its own (its local box). Around them all sits a box of Python’s own names, like print, len and max (the built-ins).
When code reads a name, Python looks in its own box first, then outward: Local, Enclosing (the box of a function it was made in), Global, Built-in. That’s the LEGB rule. The first box that has the name wins.
But Python doesn’t decide which box a name belongs to while the code runs. It decides when it reads the def. Any name the function sets, with =, +=, a for loop or a parameter, is local in the whole function. That’s true even on lines before the one that sets it. count += 1 sets count, so count is local in next_id. Python looks only in next_id’s own box, where count has no value yet.
Watch it happen. Each box is drawn inside the one Python looks in next. The player stops to ask what happens next.
Built-inPython’s own names
- len
- max
Globalrunning now
no names yet
Step 1 of 5. Before the first line runs, the global box is empty. The built-in box holds names like print and len.
count = 0 def next_id(): count += 1 return count print( next_id( ))
next_id’s box knows about count before any line runs, so count += 1 never looks outward.
Now the counter, the right way. count lives in make_counter’s box, and nonlocal count tells tick to use that one. Watch what happens to make_counter’s box after it gives back tick. Then type your own lines under the picture.
Built-inPython’s own names
- len
- max
Globalrunning now
no names yet
Step 1 of 14. Before the first line runs, the global box is empty. The built-in box holds names like print and len.
def make_counter(start): count = start def tick( ): nonlocal count count += 1 return count return tick orders = make_counter( 0) print( orders( ), orders( ))
make_counter has finished, yet its box stays, holding only count, because tick still uses it. Each call of make_counter makes a new box, so each counter has its own count. A function that keeps names from the box it was made in is called a closure.
Climb the ladder
Section titled “Climb the ladder”Each call of a counter does one addition, O(1), and each counter holds one number. What changes is where the count lives, and who else can change it.
A global count
The idea: Say global count in both functions, so they set the count in the global box.
This fixes the error: global count tells Python that count in tick means the one in the global box, so count += 1 reads and sets it there.
from collections.abc import Callable
count = 0
def make_counter(start: int = 0) -> Callable[[], int]: """Give back a function that returns start + 1, start + 2, ... one number per call.""" global count count = start
def tick() -> int: global count count += 1 return count
return tick- Time
O(1)- Space
O(1)- Why
- One addition per call, and one number for the whole program.
Where it breaks: there is only one count, and every counter shares it. After orders() twice, invoices() gives 3. And make_counter() sets count back to start, so making invoices resets orders. Any other code can change the global too.
A list as a box
The idea: Keep the count in a one-item list inside make_counter, and change box[0] in tick.
The waste in Okay: it keeps every counter’s count in one global, when each counter needs its own.
box[0] += 1 changes the list, but never sets the name box. So box isn’t local in tick: Python finds it in make_counter’s box. Each call of make_counter makes a new list, so each counter has its own.
Highlighted lines are new in this rung.
from collections.abc import Callable
def make_counter(start: int = 0) -> Callable[[], int]: """Give back a function that returns start + 1, start + 2, ... one number per call.""" box = [start]
def tick() -> int: box[0] += 1 # changes the list; never assigns the name box return box[0]
return tick- Time
O(1)- Space
O(1)- Why
- One addition per call, and one small list per counter.
What changed from the previous rung
from collections.abc import Callable
count = 0
def make_counter(start: int = 0) -> Callable[[], int]: """Give back a function that returns start + 1, start + 2, ... one number per call.""" global count count = start box = [start]
def tick() -> int: global count count += 1 return count box[0] += 1 # changes the list; never assigns the name box return box[0]
return tickWhere it breaks: nothing is wrong when it runs, but it hides what you mean. A reader sees a list of one item and wonders why. This was the usual trick before Python 3 added nonlocal.
nonlocal count
The idea: Keep count in make_counter, and say nonlocal count in tick.
The waste in Better: it wraps a number in a list only to get around the rule, when one word says what you mean.
nonlocal count tells Python that count in tick is the one in the enclosing function’s box. Python checks this when it reads the code: if no enclosing function has a count, it stops with a SyntaxError before anything runs.
Highlighted lines are new in this rung.
from collections.abc import Callable
def make_counter(start: int = 0) -> Callable[[], int]: """Give back a function that returns start + 1, start + 2, ... one number per call.""" count = start
def tick() -> int: nonlocal count count += 1 return count
return tick- Time
O(1)- Space
O(1)- Why
- One addition per call, and one number per counter.
What changed from the previous rung
from collections.abc import Callable
def make_counter(start: int = 0) -> Callable[[], int]: """Give back a function that returns start + 1, start + 2, ... one number per call.""" box = [start] count = start
def tick() -> int: box[0] += 1 # changes the list; never assigns the name box return box[0] nonlocal count count += 1 return count
return tickWhen Best isn’t best: for plain counting, itertools.count(start + 1) already exists: ids = itertools.count(1), then next(ids). And when a counter needs more actions, like reset or peek, use a small class with a count attribute. It’s clearer than several functions sharing one variable.
Write it yourself
Section titled “Write it yourself”Now write it in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere. Your code is saved in this browser for next time.
The tests make two counters and call them in turns. Try Okay from the ladder first: it passes the tests with one counter, and fails the ones with two.
Counters that count on their own
Ctrl + Enter runs- Not run yet:
orders = make_counter(); [orders(), orders(), orders()]→[1, 2, 3] - Not run yet:
invoices = make_counter(100); invoices()→101 - Not run yet:
orders = make_counter(); invoices = make_counter(); orders(); orders(); invoices()→1 - Not run yet:
orders = make_counter(); orders(); orders(); make_counter(); orders()→3 - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Time per call | Extra memory | Good | Not so good | Use it when |
|---|---|---|---|---|---|
Okay: global count | O(1) | O(1) | Fixes the error with one line | One count for every counter; a new counter resets the old ones | There is truly one of it in the program, like a setting |
| Better: a list as a box | O(1) | O(1) per counter | Each counter has its own count | A trick that hides what you mean | Python 2 code, which has no nonlocal |
Best: nonlocal count | O(1) | O(1) per counter | Says what you mean; checked before the code runs | One more line in tick | An inner function must change a name of the function it was made in |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Changing an object isn’t setting a name:
items.append(x)andbox[0] += 1only read the name, so they need noglobalornonlocal. Only=,+=and the like on the bare name do. - A name can hide a built-in: after
max = 10,max(1, 2)fails withTypeError: 'int' object is not callable, because Python finds yourmaxin the global box before the built-in one. Don’t name variableslist,dict,max,sumorid. nonlocalskips the global box: it looks only in enclosing functions. Withcountset only at the top of the file,nonlocal countis aSyntaxError: no binding for nonlocal 'count' found. Useglobalthere.- Say it first:
global xafter a line that usesxin the same function is aSyntaxError. Putglobalandnonlocalat the top of the function. - A comprehension has its own box: in
[n * n for n in nums],ndoesn’t exist after the line. - A closure keeps the variable, not its value: functions made in a loop all see the loop variable’s last value. The closures topic goes further.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Should each counter count on its own, or do all callers share one count?
- Does anything else need to read or reset the count, or only the counter itself?
- Is a class or
itertools.countallowed, or should it be one function?
A 60-second walkthrough
“Python decides a name’s scope when it compiles the function: if the function assigns the name anywhere, it’s local everywhere in that function. So
count += 1makescountlocal, and reading it before it’s set is anUnboundLocalError. Reading only works because lookup goes local, enclosing, global, then built-in. I keepcountinsidemake_counter, so each counter gets its own, and saynonlocal countintickso the assignment goes to the enclosing function’s variable. Each call is O(1). A global would work for one counter, but every counter would share it.”
Likely follow-ups
- What is LEGB? The order Python looks for a name: local, enclosing, global, built-in.
- What’s the difference between
globalandnonlocal?globalpoints a name at the global box.nonlocalpoints it at the nearest enclosing function that has it. - Why does reading a global work but
+=fail?+=sets the name, which makes it local in the whole function. - What keeps
countalive aftermake_counterreturns?tickis a closure: it keeps a reference tomake_counter’s variable, which you can see intick.__closure__.
Practice
Section titled “Practice”Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. The first check that failed (easy)
Section titled “1. The first check that failed (easy)”Write first_failure(results). results is a list of checks in the order they ran, each a (name, passed) pair. Return the name of the first check that failed, or None if every check passed or there were none.
Example: first_failure([("lint", True), ("unit", False), ("e2e", False)]) gives "unit".
Hint 1 of 3
A for loop is not a scope: its variables live on after the loop, holding the last item.
Hint 2 of 3
With an empty list, the loop body never runs, so the loop variable is never set.
Hint 3 of 3
next() takes a default, for when a generator has nothing to give.
The first check that failed
Ctrl + Enter runs- Not run yet:
first_failure([("lint", True), ("unit", False), ("e2e", False)])→'unit' - Not run yet:
first_failure([("lint", True), ("unit", True)])→None - Not run yet:
first_failure([])→None - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Break, then return the loop variable
The idea: Loop until a check failed, break, and return name, which is still set after the loop.
A for loop isn’t a scope, so name is still there after the loop.
def first_failure(results: list[tuple[str, bool]]) -> str | None: """The name of the first check that failed, or None if every check passed.""" for name, passed in results: # noqa: B007 (name is used after the loop) if not passed: break return name # still set after the loop: the last name seen- Time
O(n)- Space
O(1)- Why
- It stops at the first failure.
Where it breaks: when every check passed, the loop ends without break, and name holds the last check’s name, not None. With no checks, the loop never sets name, and returning it is an UnboundLocalError.
Set the answer before the loop
The idea: Start with failed = None, and set it only when a check fails.
The waste in Okay: it reuses the loop variable as the answer, when the loop’s last item and the answer are different things.
failed is set before the loop, so it has a value however the loop ends.
Highlighted lines are new in this rung.
def first_failure(results: list[tuple[str, bool]]) -> str | None: """The name of the first check that failed, or None if every check passed.""" failed = None for name, passed in results: if not passed: failed = name break return failed- Time
O(n)- Space
O(1)- Why
- The same single pass.
What changed from the previous rung
def first_failure(results: list[tuple[str, bool]]) -> str | None: """The name of the first check that failed, or None if every check passed.""" for name, passed in results: # noqa: B007 (name is used after the loop) failed = None for name, passed in results: if not passed: failed = name break return name # still set after the loop: the last name seen return failednext() with a default
The idea: Make a generator of the failed names, and take the first with next(..., None).
The waste in Better: five lines and a flag for “the first item that matches, or None”, which next() says in one.
The generator has its own scope, so name and passed don’t exist after the line.
Highlighted lines are new in this rung.
def first_failure(results: list[tuple[str, bool]]) -> str | None: """The name of the first check that failed, or None if every check passed.""" return next((name for name, passed in results if not passed), None)- Time
O(n)- Space
O(1)- Why
- The generator stops at the first failure too.
What changed from the previous rung
def first_failure(results: list[tuple[str, bool]]) -> str | None: """The name of the first check that failed, or None if every check passed.""" failed = None for name, passed in results: if not passed: failed = name break return failed return next((name for name, passed in results if not passed), None)When Best isn’t best: if you need more than the first failure, like every failed name or a count, write the loop or a list comprehension instead.
2. Clear the history (medium)
Section titled “2. Clear the history (medium)”A module keeps a list of events in a global, history, and record(event) adds to it. Write clear_history(), which empties the history. Other parts of the program may hold the list too, like a page that did from log import history: they must see it empty as well.
Example: after record("login"), then clear_history(), history is [], and a later record("buy") makes it ["buy"].
Hint 1 of 3
history = [] inside a function makes a new local name. The global list doesn’t change.
Hint 2 of 3
global history then history = [] changes the global name, but anyone holding the old list still has the old events.
Hint 3 of 3
Empty the list itself, so every name that holds it sees the change.
Clear the history
Ctrl + Enter runs- Not run yet:
clear_history(); record("login"); record("buy"); history→['login', 'buy'] - Not run yet:
record("login"); clear_history(); history→[] - Not run yet:
saved = history; record("login"); clear_history(); saved→[] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Set history to a new list
The idea: Write history = [] in clear_history.
It runs without an error, which is what makes it hard to spot.
history: list[str] = []
def record(event: str) -> None: history.append(event)
def clear_history() -> None: history = [] # noqa: F841 (a new local name: the global list is untouched)- Time
O(1)- Space
O(1)- Why
- It makes one empty list.
Where it breaks: history = [] sets a name, so history is local in clear_history. The new empty list is thrown away when the call ends, and the global list keeps every event. Ruff flags it as F841: a local that’s set but never used.
global history
The idea: Say global history, then set it to a new empty list.
The waste in Okay: it makes a new list in a box that’s thrown away, when the name to change is in the global box.
Now history in the global box points to the new list, and record adds to it.
Highlighted lines are new in this rung.
history: list[str] = []
def record(event: str) -> None: history.append(event)
def clear_history() -> None: global history history = []- Time
O(1)- Space
O(1)- Why
- It makes one empty list; the old one is freed once nothing holds it.
What changed from the previous rung
history: list[str] = []
def record(event: str) -> None: history.append(event)
def clear_history() -> None: history = [] # noqa: F841 (a new local name: the global list is untouched) global history history = []Where it breaks: a name only points to a list. Code that did saved = history, or from log import history, still points to the old list with the old events, and never sees a new one.
Empty the list itself
The idea: Call history.clear(), which changes the list and never sets the name.
The waste in Better: it makes a second list, when everyone already holds the first.
history.clear() only reads the name history, so Python finds it in the global box with no global at all. del history[:] does the same.
Highlighted lines are new in this rung.
history: list[str] = []
def record(event: str) -> None: history.append(event)
def clear_history() -> None: history.clear() # changes the list itself; the name history is only read- Time
O(n)- Space
O(1)- Why
- It drops each of the n events from the one list.
What changed from the previous rung
history: list[str] = []
def record(event: str) -> None: history.append(event)
def clear_history() -> None: global history history = [] history.clear() # changes the list itself; the name history is only readWhen Best isn’t best: a list that many parts of a program change is hard to follow. A small class, like EventLog with record() and clear(), keeps the list and every change to it in one place.
3. A scoreboard (hard)
Section titled “3. A scoreboard (hard)”Write make_scoreboard(). It gives back three functions that share one board: add(score) adds a whole number, best() gives back the highest score so far, or None if there is none, and reset() starts the board over. A game calls add and best after every round, for up to 30,000 rounds, so best() must be fast.
Example: add, best, reset = make_scoreboard(), then add(5) and add(9), gives best() as 9. After reset(), best() is None.
Hint 1 of 3
scores = [] in reset makes a new local list. The board’s list doesn’t change.
Hint 2 of 3
max() over every score on every call adds up: 30,000 rounds look at about 450 million scores.
Hint 3 of 3
best() only needs the highest score. Keep just that one number, and change it with nonlocal.
A scoreboard
Ctrl + Enter runs- Not run yet:
add, best, reset = make_scoreboard(); add(5); add(9); add(7); best()→9 - Not run yet:
add, best, reset = make_scoreboard(); best()→None - Not run yet:
add, best, reset = make_scoreboard(); add(5); reset(); best()→None - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
A list, and a new list to reset
The idea: Keep every score in a list, take max() in best(), and set scores = [] in reset().
add changes the list with append, so it finds scores in make_scoreboard’s box without nonlocal.
from collections.abc import Callable
def make_scoreboard() -> tuple[Callable[[int], None], Callable[[], int | None], Callable[[], None]]: """Give back add(score), best() and reset(), sharing one board.""" scores: list[int] = []
def add(score: int) -> None: scores.append(score)
def best() -> int | None: return max(scores, default=None)
def reset() -> None: scores = [] # noqa: F841 (a new local name: the board's list is untouched)
return add, best, reset- Time
O(n) per best()- Space
O(n)- Why
- best() looks at all n scores so far, and the list keeps them all.
Where it breaks: scores = [] in reset sets a name, so it’s a new local, thrown away when reset ends. The board keeps every score. And max() over every score on every round is too slow for 30,000 rounds.
Clear the list in reset
The idea: Call scores.clear() in reset(), so the board’s own list is emptied.
The waste in Okay: reset makes a list nobody sees, when the board’s list is the one to empty.
Highlighted lines are new in this rung.
from collections.abc import Callable
def make_scoreboard() -> tuple[Callable[[int], None], Callable[[], int | None], Callable[[], None]]: """Give back add(score), best() and reset(), sharing one board.""" scores: list[int] = []
def add(score: int) -> None: scores.append(score)
def best() -> int | None: return max(scores, default=None)
def reset() -> None: scores.clear()
return add, best, reset- Time
O(n) per best()- Space
O(n)- Why
- reset() works now, but best() still looks at every score.
What changed from the previous rung
from collections.abc import Callable
def make_scoreboard() -> tuple[Callable[[int], None], Callable[[], int | None], Callable[[], None]]: """Give back add(score), best() and reset(), sharing one board.""" scores: list[int] = []
def add(score: int) -> None: scores.append(score)
def best() -> int | None: return max(scores, default=None)
def reset() -> None: scores = [] # noqa: F841 (a new local name: the board's list is untouched) scores.clear()
return add, best, resetWhere it breaks: speed. Round n looks at n scores, so 30,000 rounds look at about 450 million. That takes several seconds, and the test stops it.
Keep only the top score
The idea: Keep one number, top. add() raises it with nonlocal, best() reads it, reset() sets it back to None.
The waste in Better: it keeps every score and searches them all, when best() only ever needs the highest.
add and reset set top, so they need nonlocal top. best only reads it, so Python finds it in make_scoreboard’s box by itself.
Highlighted lines are new in this rung.
from collections.abc import Callable
def make_scoreboard() -> tuple[Callable[[int], None], Callable[[], int | None], Callable[[], None]]: """Give back add(score), best() and reset(), sharing one board.""" top: int | None = None
def add(score: int) -> None: nonlocal top if top is None or score > top: top = score
def best() -> int | None: return top # only read here, so no nonlocal is needed
def reset() -> None: nonlocal top top = None
return add, best, reset- Time
O(1) per callwas O(n) per best()- Space
O(1)was O(n)- Why
- Each call does one comparison at most, and the board holds one number.
What changed from the previous rung
from collections.abc import Callable
def make_scoreboard() -> tuple[Callable[[int], None], Callable[[], int | None], Callable[[], None]]: """Give back add(score), best() and reset(), sharing one board.""" scores: list[int] = [] top: int | None = None
def add(score: int) -> None: scores.append(score) nonlocal top if top is None or score > top: top = score
def best() -> int | None: return max(scores, default=None) return top # only read here, so no nonlocal is needed
def reset() -> None: scores.clear() nonlocal top top = None
return add, best, resetWhen Best isn’t best: keeping one number throws the other scores away. If the game later needs the top ten or an average, keep the list. Add a heap (heapq) or a running total next to it.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- Python looks for a name in this order: local, enclosing, global, built-in (LEGB).
- A name a function sets anywhere is local in the whole function. Reading it before it’s set is an
UnboundLocalError. global namesets the name in the global box.nonlocal namesets it in the nearest enclosing function’s box.- Changing an object, like
items.append(x), only reads the name: noglobalneeded. - Only functions, classes and comprehensions make a new scope. A
forloop orifblock doesn’t.
Go deeper
Section titled “Go deeper”- Naming and binding, in the Python reference: how scopes are decided.
- From the Python FAQ: Why am I getting an UnboundLocalError when the variable has a value? and the rules for local and global variables.
- The global statement and the nonlocal statement.
- Draw your own: the scope boxes on the Python visualizer.
- Next on the Python roadmap: Comprehensions, and later Closures, which build on this lesson.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started