Where a name lives, and why the function cannot see it
Four places Python looks for a name
When you use a name, Python searches in this order and stops at the first hit:
- Local — names assigned inside the current function.
- Enclosing — names in a function that contains this one.
- Global — names at the top level of the module.
- Built-in —
print,len,sumand the rest.
That is the LEGB rule, and it explains both directions of confusion.
Reading a global from inside a function works without ceremony:
TAX_RATE = 0.18
def with_tax(amount):
return amount * (1 + TAX_RATE) # fine, found at step 3Assigning changes everything
count = 0
def bump():
count = count + 1 # UnboundLocalError
bump()UnboundLocalError: cannot access local variable 'count' where it is not associated with a valueThe reason is precise and worth knowing. When Python compiles the function, it scans the whole body. Because count is assigned somewhere in it, count is marked local for the entire function — including the line that tries to read it before the assignment. The global count becomes invisible inside bump, so the read on the right-hand side has nothing to fetch.
The surprising part is that this happens even when the assignment comes later:
def report():
print(TAX_RATE) # UnboundLocalError, despite line 1 looking harmless
TAX_RATE = 0.05A single assignment anywhere in the body decides the name's scope for all of it. This is a compile-time decision, not a runtime one.
global, and why to avoid needing it
count = 0
def bump():
global count
count += 1That works. It also means any function anywhere can now change count, and when the value is wrong you have to read the whole program to find out who changed it. Every additional global roughly doubles the search space when debugging.
Better shapes, in order of preference:
def bump(count):
return count + 1 # take it in, hand it back
count = bump(count)or keep the state in an object, or in a dictionary passed explicitly. Module-level constants in capitals, read but never assigned, are fine and normal; global for mutable state is the thing to avoid.
Mutation slips through without global
totals = []
def record(x):
totals.append(x) # works — no `global` neededNo error, and the list really does change. The rule from the previous lesson applies: totals.append(x) does not assign to the name totals, it calls a method on the object the name already refers to. Only assignment triggers the local-name rule.
So totals = [] inside the function would need global, and totals.append(x) does not. That asymmetry catches people who learned the global rule as "you cannot change globals from a function".
nonlocal, for nested functions
def counter():
n = 0
def increment():
nonlocal n
n += 1
return n
return incrementnonlocal says "the name in the enclosing function, not a new local and not a module global". Without it, n += 1 would be an UnboundLocalError inside increment. You meet this in closures and decorators, both of which appear later in the course.
Loops and if do not create a scope
Unlike most languages with braces, Python only creates a new scope for a function, a class, or a module. A variable first assigned inside a for or an if is visible after it:
for row in rows:
last = row
print(last) # works — unless rows was empty, then NameErrorThat is convenient and it produces one specific bug: if the loop ran zero times, the name was never created, and the line after the loop raises NameError rather than giving you an empty result. Initialise before the loop when the code after it depends on the name.
Shadowing a built-in
list = [1, 2, 3]
list("abc") # TypeError: 'list' object is not callableAssigning to list, dict, sum, id, type or input hides the built-in for the rest of the module. Nothing warns you at the moment you do it; the failure comes later, somewhere else, in code that had every right to expect list to be a type. Add a trailing underscore — list_ — or pick a better name.
Free linters catch this in one command: ruff check flags shadowed built-ins, unused names and the loop-variable problems above, and it runs in under a second on a whole project.
The one thing to keep
A name assigned anywhere in a function is local for the whole function, which is why reading a global before assigning it raises UnboundLocalError, while mutating a global object needs no declaration at all.
Before you move on
A function reads `config` on its first line and, twenty lines later, does `config = {}` in an error branch that has never yet run. Every call now fails on line 1 with UnboundLocalError. Why does an unreached line break the first line?
Pick the one you would defend. Nobody sees your answer.