Variable Scope & the LEGB Rule
In short: every name lookup in Python checks four scopes, in this exact
order: Local (the current call) → Enclosing (any function this one is
defined inside) → Global (the module) → Built-in (len, sum, …).
Python stops at the first scope that has the name. global and
nonlocal don't create a name — they tell an assignment which existing
scope to write into, instead of making a new local. Most scope bugs come from
forgetting that a name read anywhere in a function makes Python search
outward, but a name assigned anywhere in a function makes it local for the
whole function, from the top.

A three-city pricing engine
A pricing tool has one module-wide default tax rate, but each region can override it. Build the override once per region, and let every price lookup inside that region read it — without ever touching the global directly:
price_it never mentions TAX_RATE. It asks for rate, and Python's search
finds it one step out, in region_pricing — the Enclosing scope — and
never even looks at the module. That's the order that trips people up: E is
checked before G, not after.
LIMIT = 100
def make_checker():
LIMIT = 50 # a same-named local in the ENCLOSING function
def over():
return LIMIT # which one does this find?
return over
check = make_checker()
print(check())A score tracker with a memory
A game keeps a running high score per session. nonlocal doesn't mean
"global" — it means "the nearest enclosing scope that already has this
name", which can be one level out or several:
wrap_round reads nothing and assigns nothing that's shared, so it needs no
nonlocal at all — the rule only cares about names a function itself
assigns. nonlocal climbs outward one enclosing scope at a time and stops
at the first one that already defines the name; it never reaches the module
level (that's what global is for).
UnboundLocalErrorRun it and read the error. Then let record remember the best score between calls.
Shadowing a name you didn't mean to
A function that reuses a built-in's name doesn't fail right away — it fails later, somewhere that name is needed:
def summarise(items):
list = len(items) # shadows the built-in list() inside this function
empty = list([]) if list == 0 else "n/a"
return empty
print(summarise([]))Never name a variable the same as a built-in you (or code near it) still
need: list, sum, id, type, input, dict are the usual victims.
Common scope bugs, named
- UnboundLocalError from a read-before-write. If a function assigns to a
name anywhere — even in an
ifthat doesn't run — that name is local for the whole function, and reading it before the assignment line fails. - A
forloop variable outlives the loop. Unlike many languages, Python has no block scope:for i in range(5): ...leavesi= 4 readable right after the loop, in the same scope. It does not leak into an enclosing or global scope though — only within the block's own function/module. global/nonlocalmust come before the first use in that function, and only affects the function it's written in — not functions it calls.
Quick practice
x = "outer"
def show():
print(x)
x = "inner"
show()def counter_demo():
n = 0
def bump():
n = n + 1 # looks like it updates n...
return n
return bump
tick = counter_demo()
print(tick())UnboundLocalErrorMake add actually accumulate across calls.
for i in range(3):
last_seen = i
print(last_seen)🎯 Your turn
Write priced_list(bases, override_rate=None). It builds one region
pricer (like region_pricing above) and returns the price of every base
amount in bases, each rounded to 2 decimals, at 18% tax by default — or at
override_rate when one is given.
priced_list([1000])→[1180.0]priced_list([1000], override_rate=0.0)→[1000.0]
Hint: the module already has TAX_RATE = 0.18. Build one inner
price_it closure that reads rate from its enclosing scope, then apply it
to every base with a list comprehension: [price_it(b) for b in bases].
Press ▶ Run, try the Live App chips, then ✓ Check. After that, the practice set below goes from a single safe read to a full closures-and-globals tour.
