Skip to content

Python says my counter has no value

  • Python · Foundations
  • 15 min read, about 1 h with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

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 value

Even more surprising, reading it works: def show(): return count gives 0. Only adding to it fails. Why?

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, then start + 2, and so on.
  • The hard part: each counter keeps its own count. Calling orders() never moves invoices, 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.

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.

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.

count += 1 looks only in next_id’s box

Built-inPython’s own names

  • print
  • 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())

Try your own input

Indent with spaces. It knows def, return, global, nonlocal, pass, =, +=, -=, calls, whole numbers, text, True, False, None, lists with [i], .append() and .clear(), and the built-ins print, len, max, min, abs, sum and str.

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.

tick changes make_counter’s count

Built-inPython’s own names

  • print
  • 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())

Try your own input

Indent with spaces. It knows def, return, global, nonlocal, pass, =, +=, -=, calls, whole numbers, text, True, False, None, lists with [i], .append() and .clear(), and the built-ins print, len, max, min, abs, sum and str.

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.

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.

Okay

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.

Better

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 tick

Where 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.

Best

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 tick

When 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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: orders = make_counter(); [orders(), orders(), orders()] → [1, 2, 3]
  2. Not run yet: invoices = make_counter(100); invoices() → 101
  3. Not run yet: orders = make_counter(); invoices = make_counter(); orders(); orders(); invoices() → 1
  4. Not run yet: orders = make_counter(); orders(); orders(); make_counter(); orders() → 3
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayTime per callExtra memoryGoodNot so goodUse it when
Okay: global countO(1)O(1)Fixes the error with one lineOne count for every counter; a new counter resets the old onesThere is truly one of it in the program, like a setting
Better: a list as a boxO(1)O(1) per counterEach counter has its own countA trick that hides what you meanPython 2 code, which has no nonlocal
Best: nonlocal countO(1)O(1) per counterSays what you mean; checked before the code runsOne more line in tickAn inner function must change a name of the function it was made in
  • Changing an object isn’t setting a name: items.append(x) and box[0] += 1 only read the name, so they need no global or nonlocal. Only =, += and the like on the bare name do.
  • A name can hide a built-in: after max = 10, max(1, 2) fails with TypeError: 'int' object is not callable, because Python finds your max in the global box before the built-in one. Don’t name variables list, dict, max, sum or id.
  • nonlocal skips the global box: it looks only in enclosing functions. With count set only at the top of the file, nonlocal count is a SyntaxError: no binding for nonlocal 'count' found. Use global there.
  • Say it first: global x after a line that uses x in the same function is a SyntaxError. Put global and nonlocal at the top of the function.
  • A comprehension has its own box: in [n * n for n in nums], n doesn’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.

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.count allowed, 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 += 1 makes count local, and reading it before it’s set is an UnboundLocalError. Reading only works because lookup goes local, enclosing, global, then built-in. I keep count inside make_counter, so each counter gets its own, and say nonlocal count in tick so 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 global and nonlocal? global points a name at the global box. nonlocal points 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 count alive after make_counter returns? tick is a closure: it keeps a reference to make_counter’s variable, which you can see in tick.__closure__.

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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: first_failure([("lint", True), ("unit", False), ("e2e", False)]) → 'unit'
  2. Not run yet: first_failure([("lint", True), ("unit", True)]) → None
  3. Not run yet: first_failure([]) → None
  4. Not run yet: Extra test
  5. 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
Okay

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.

Better

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 failed
Best

next() 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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: clear_history(); record("login"); record("buy"); history → ['login', 'buy']
  2. Not run yet: record("login"); clear_history(); history → []
  3. Not run yet: saved = history; record("login"); clear_history(); saved → []
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. 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
Okay

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.

Better

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.

Best

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 read

When 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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: add, best, reset = make_scoreboard(); add(5); add(9); add(7); best() → 9
  2. Not run yet: add, best, reset = make_scoreboard(); best() → None
  3. Not run yet: add, best, reset = make_scoreboard(); add(5); reset(); best() → None
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. 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
Okay

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.

Better

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, reset

Where 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.

Best

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, reset

When 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.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat happens?
    total = 10
    
    def add(n):
        print(total)
        total = total + n
    
    add(5)
  2. Predict the outputWhat does this print?
    x = "global"
    
    def outer():
        x = "enclosing"
        def inner():
            return x
        return inner()
    
    print(outer())
  3. Spot the bugclear_cart() runs without an error, but the cart still holds every item. Why?
    cart = []
    
    def add(item):
        cart.append(item)
    
    def clear_cart():
        cart = []
  4. Multiple choiceWhich of these makes a new scope in Python?
  5. Multiple choiceInside an inner function, which line changes count in the function it was made in?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • 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 name sets the name in the global box. nonlocal name sets it in the nearest enclosing function’s box.
  • Changing an object, like items.append(x), only reads the name: no global needed.
  • Only functions, classes and comprehensions make a new scope. A for loop or if block doesn’t.

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