Python scope decides where a name lives and which name wins when two places use the same word. The LEGB rule is the whole system in four letters, and once you can recite it, the confusing errors stop being mysterious.
What is scope and why does it exist?
A scope is a region of a program where a name is visible. Every variable you write belongs to one, and Python uses scope to keep two unrelated pieces of code from colliding over the word count. When you write total = 0 inside a function, that total is a different variable from a total in the file above it, even though they share a name.
The point is not bureaucracy. It is that a function should be able to assume it is the only code running, that its helper variables cannot be corrupted by unrelated code, and that reading a function tells you exactly which values it depends on. Scope is how Python delivers that without asking you to declare anything.
Two terms are worth separating. A namespace is the container itself: the module's dictionary, a function's mapping of local names, the built-in table. A scope is the region of code that can reach a namespace. You rarely need the distinction, but it explains why globals() and locals() return dictionaries.
What does the LEGB rule mean?
Every time Python evaluates a name, it looks in four places, in this order, and stops at the first one that has it.
| Letter | Scope | Where it comes from |
|---|---|---|
| L | Local | Names assigned inside the current function |
| E | Enclosing | Names in the function that called the current one |
| G | Global | Names at the top level of your module |
| B | Built-in | Python's own names: print, len, range |
def demo():
label = "local name"
print(label)
demo()
print(len("python"))
print(sorted([3, 1, 2]))
The built-in scope is the one people forget, and it explains a class of confusing errors. If you write list = [1, 2, 3] at the top of a module, you have shadowed the built-in list for that whole file, and any later call to list(...) now fails with TypeError: 'list' object is not callable. Nothing about the built-in changed; your name simply won the lookup.
Search order also explains a small optimisation that surprises people: Python compiles a function's local names once, when it sees the def, so a name that is assigned anywhere in the body is a local for the entire body. That single fact is the cause of the error in the next-but-one section.
Why does Python have no block scope?
An if, a for loop, a while loop and a try block do not create a scope. Variables assigned inside them live in whatever scope encloses the block, usually the module or the function that contains it.
if True:
level = "set inside an if block"
print(level)
for item in ["a", "b"]:
last = item
print(last, item)
Only a handful of constructs introduce a new scope: functions, classes, modules, and comprehensions. That is why a loop variable like item is still there after the loop finishes, and why it can shadow an outer variable of the same name without any warning.
Comprehensions are the exception people rarely expect, because they look like a loop but behave like a function. The body of a comprehension has its own scope, so the loop variable does not leak out.
doubled = [n * 2 for n in range(4)]
print(doubled)
try:
print(n)
except NameError as exc:
print("NameError:", exc)
That difference between a loop and a comprehension is the reason comprehension variables do not leak. It also means a comprehension cannot see the names of the module it appears in unless you pass them in, which surprises people the first time they try to build a list from a global threshold inside a function.
What causes an UnboundLocalError?
This is the error that teaches LEGB better than any explanation. Inside a function, a name that is assigned anywhere in the body is local for the whole body. If you read that name before the assignment runs, Python refuses to fall back to the global, because the local one exists, it just has no value yet.
count = 0
def report():
try:
print(count)
except UnboundLocalError as exc:
print("UnboundLocalError:", exc)
count = 5
report()
print("global count is still:", count)
The fix is not to sprinkle global everywhere. The fix is to rename the local variable, or to pass the value in as a parameter. Renaming is almost always right: a function that mutates a global is a function whose behaviour depends on invisible state, and two calls to it can give different answers.
Understanding this rule also explains a subtle bug: a function that reads a global and then assigns to a different variable of the same name elsewhere is fine, but a function that reads and assigns the same name is not. The compiler does not care about the order of your statements; it cares that the name is assigned in the body.
When do you need the global keyword?
Rarely. global tells Python to bind a name to the module-level namespace instead of creating a local one. It exists for genuine module-level state, such as a configuration flag that a command-line handler flips once at startup.
count = 0
def bump():
global count
count += 1
bump()
bump()
print("after two bumps:", count)
Notice what global does not do: it does not make a global visible inside a function. A global is visible in every scope regardless, because it is further down the LEGB chain. global only changes where an assignment lands.
That distinction explains a split personality that catches everyone eventually:
items = []
def add_mutating(value):
items.append(value) # mutates the shared list, no global needed
def add_rebinding(value):
global items
items = items + [value] # rebinds the module-level name
add_mutating("a")
add_rebinding("b")
print(items)
Appending to a list works without global because you are changing the object the name points at. Rebinding replaces the name itself, so without global Python would create a local copy and throw the original away. The same distinction shows up in lesson six, where copying and referencing lists turns out to be the same idea.
How do closures fit into scope?
When a nested function refers to a name from the function that contains it, that name is enclosing scope — the E in LEGB. The inner function carries a reference to the outer function's scope, and that pairing is called a closure.
def make_accumulator():
total = 0
def add(amount):
nonlocal total
total += amount
return total
add(10)
add(5)
return total
print("accumulated:", make_accumulator())
nonlocal is the counterpart to global: it binds the name to the nearest enclosing function scope rather than creating a local. Without it, total += amount would create a new local total and raise the same UnboundLocalError from the previous section.
Closures are how you build stateful objects without writing a class: a counter, a rate limiter, a memoising cache. You will build one properly in the advanced-functions module. For now the important idea is that a nested function can read and, with nonlocal, update the state of the function that created it.
Key takeaways
- Python resolves a name by checking local, then enclosing, then global, then built-in. The first hit wins and nothing after it is consulted.
- Blocks do not create scopes. Functions, classes, modules and comprehensions do.
- A name assigned anywhere inside a function is local to that whole function, which is the entire reason
UnboundLocalErrorexists. - Mutating a shared object needs no
global; rebinding its name does. Prefer parameters over globals, and rename rather than reaching forglobal. nonlocalreaches one scope outwards, into the function that defined the nested one.
Try it yourself: write def make_counter() that returns an inner function which increments and returns a counter, using nonlocal rather than a class. Then write def report() that tries to read a module-level variable and assign to it in the same body, catch the UnboundLocalError deliberately, and then fix it by renaming. Finally, write a loop that assigns a variable and a comprehension that does the same, and use locals() and globals() to print which scope each name ended up in.