Why Scope Exists — The Rooms in Your House
Python functions work exactly like these rooms. Each function has its own private whiteboard — a local scope. Names written there don't leak into other rooms. This is what keeps a 50,000-line codebase sane: two functions can both use the variable
i without stepping on each other.
A scope is the region of a program where a name (variable, function, class) is directly accessible. Python decides which value belongs to a name by looking at scopes in a fixed order. Understanding that order is the difference between "the code works" and "I have no idea why the code works".
Every time a function runs, Python creates a fresh local namespace for that call and destroys it when the function returns. Names created inside a function are born and die with that call — unless you explicitly say otherwise.
The LEGB Rule — Python's Scope Lookup Order
Whenever Python sees a name being read, it searches four scopes in this exact order, stopping at the first match. Memorize the acronym LEGB and you have solved 90% of all scope confusion.
print, len, range, str. Always available. Checked last.# Global scope
x = "global-x"
def outer():
x = "enclosing-x" # Enclosing scope
def inner():
x = "local-x" # Local scope
print("inside inner:", x) # finds Local first
inner()
print("inside outer:", x) # finds Enclosing
outer()
print("module level: ", x) # finds Global
print(len(x)) # len comes from Built-in
When you can't figure out which x a piece of code is using, walk outward
one ring at a time: current function → any function wrapping it → module top → Python
itself. The first place the name exists wins.
Local Scope — What Happens Inside a Function, Stays Inside
Any name that Python sees being assigned inside a function body is treated
as local to that function — unless you explicitly tag it global or
nonlocal. This local-by-default rule is Python's most important scope decision.
def make_greeting():
message = "Hello" # local to make_greeting
count = 3 # local to make_greeting
print(message, count)
make_greeting()
print(message) # NameError — message does not exist here
Each Call Gets a Fresh Local Scope
def counter():
n = 0 # fresh n on every call
n += 1
print(f"n = {n}")
counter()
counter()
counter()
The name n is born when counter() starts and destroyed when it
returns. It has zero memory between calls. If you want state that persists across calls,
you need a global, a class attribute, or a closure — not a local variable.
Reading Globals from Inside a Function — Works Fine
You can read a global variable from inside a function without any special syntax. Python's LEGB search finds it in the Global scope after failing to find it Locally. No error, no ceremony.
APP_NAME = "PaymentService" # global
VERSION = "2.4.1" # global
def show_banner():
# Only READING globals — perfectly legal
print(f"=== {APP_NAME} v{VERSION} ===")
show_banner()
show_banner()
Constants, imported modules, configuration values, and helper functions defined at the top of a file are all globals that get freely read inside functions. This is the normal, healthy use of globals — reading them, not writing them.
The Famous Trap — UnboundLocalError
counter. Your function reads it and prints it — works fine.
Then you add one innocent line: counter += 1. Suddenly the whole function
explodes with UnboundLocalError: local variable 'counter' referenced before assignment.
You didn't touch the print line, so why is it broken?This is the single most confusing error in Python. Understanding it teaches you how Python's compiler thinks — and once you see it, you can never un-see it.
counter = 0 # global
def bump():
print("before:", counter) # seemed fine before...
counter += 1 # ...but this line changes EVERYTHING
print("after: ", counter)
bump()
print, Not the +=
When Python compiles the function body, it scans it once and marks every name
that gets assigned anywhere in the function as local. It sees
counter += 1 further down and decides counter is local for the
whole function. Then the earlier print(counter) tries to read a local
variable that hasn't been assigned yet — and fails.
What Python Actually Sees
bump()counter += 1 — that's an assignment to counter.
counter as a local name for this function.
print tries to read local counter.
counter has never been given a value yet. UnboundLocalError.
If a function assigns to a name anywhere in its body, that name is local everywhere in that function — even on lines that come before the assignment. There's no half-local, half-global. It's local for the entire scope.
The global Keyword — Reaching Out to Modify Globals
To actually modify a global variable from inside a function, you have to declare it
global at the top of the function. This tells Python: "don't create a local
counter — reuse the module-level one".
counter = 0 # global
def bump():
global counter # "I mean THE module-level counter"
print("before:", counter)
counter += 1 # now modifies the global
print("after: ", counter)
bump()
bump()
bump()
print("final: ", counter)
Before vs After — What global Actually Changes
global| Behavior | What happens |
|---|---|
| Assignment | Creates a local shadow variable |
| Read of same name | UnboundLocalError |
| Module state | Unchanged after function returns |
| Bug likelihood | Very high — hidden shadowing |
global| Behavior | What happens |
|---|---|
| Assignment | Modifies the module-level variable |
| Read of same name | Reads the same global — no error |
| Module state | Persists across all future calls |
| Bug likelihood | Explicit — reader knows what's changing |
global Sparingly — It's a Code Smell at Scale
Every function that mutates a global becomes an invisible dependency on every other
function that reads that global. Testing gets hard, concurrency gets scary, and bugs get
hard to trace. Prefer returning values, passing arguments, or using a class attribute.
Reserve global for genuine module-wide state — configuration flags, singletons,
caches — not general communication between functions.
The Mutation Loophole — Why Lists and Dicts Seem to "Just Work"
Here's something that trips up almost everyone. This code has no
global declaration and yet it works fine:
cart = [] # global list
def add_item(item):
cart.append(item) # no 'global cart' needed?!
add_item("book")
add_item("pen")
print(cart)
cart.append(x) does not assign to cart — it mutates
the object that cart already points to. Python's scope rules only care about
assignment. Reading a name and calling a method on it doesn't count as
assignment, so no local shadow gets created, and the global object gets modified.
cart = []
def reset_cart():
cart = [] # this is REBINDING -> creates a local!
cart.append("fresh")
reset_cart()
print("global cart:", cart) # still []
| Operation inside function | Is it assignment? | Does it touch the global? | Needs global? |
|---|---|---|---|
x = 5 | Yes | No — creates local | Yes, to modify global |
x += 1 | Yes (rebind) | No — creates local | Yes, to modify global |
lst.append(v) | No — method call | Yes — mutates in place | No |
d[k] = v | No — item assignment | Yes — mutates in place | No |
lst = [1, 2] | Yes (rebind) | No — creates local | Yes, to modify global |
print(x) | No — read only | Yes — reads global | No |
Enclosing Scope and the nonlocal Keyword
When one function is defined inside another, the outer function's variables form an
enclosing scope for the inner function. The inner function can read them
freely, but assigning creates a local shadow — same trap as globals. To actually modify
the enclosing variable, use nonlocal.
def make_counter():
count = 0 # enclosing variable
def increment():
nonlocal count # reach outward to enclosing scope
count += 1
return count
return increment
tick = make_counter()
print(tick())
print(tick())
print(tick())
| Keyword | Target scope | Typical use |
|---|---|---|
global | Module (top) level | Modify a module-level variable from any function |
nonlocal | Nearest enclosing function | Modify an outer function's variable from a nested one (closures, decorators) |
The tick function above remembers the count from
make_counter even after make_counter has returned. That's a
closure: an inner function plus the enclosing scope it captured. It's
the foundation of decorators, callbacks, and functional patterns in Python.
Golden Rules
UnboundLocalError: referenced before assignment happens even when the read
appears above the write.
global at the top of the function.
global only for genuinely module-wide state (config, singletons, caches).
For general communication between functions, return values and pass arguments
instead. Mutable globals make code hard to test and reason about.
lst.append, d[k] = v) is not
assignment — it doesn't create a local shadow. That's why you don't need
global to modify a global list or dict in place. But it's also why
accidental mutation is one of Python's easiest bugs to create.
nonlocal to modify an enclosing function's variable from a nested
function. This is how closures with mutable state (counters, memoization,
simple decorators) are built.