Scope & LEGB
Every name lookup in Python follows a fixed search order — Local, Enclosing, Global, Built-in — and global/nonlocal are the only ways to reach outside the local scope for assignment.
Scope determines where a name is visible and how long it lives. Python resolves every name reference by searching four nested scopes in a fixed order — Local, Enclosing, Global, Built-in, remembered by the acronym LEGB. Reading a name walks outward through these scopes until a match is found; but *assigning* to a name inside a function makes it local by default, which is where global and nonlocal become necessary tools for reaching outward on purpose.
What Is Scope
A scope is a region of code where a particular name-to-object binding is visible. Python creates a new local scope for each function call, a new enclosing scope for each nested function, one global scope per module, and a built-in scope shared by everything.
L — Local Scope
Names assigned anywhere inside a function body (including parameters) belong to that function's local scope, and exist only while that particular call is executing.
def greet():
message = "Hello" # local to greet()
print(message)
greet()
print(message) # NameError: 'message' is not defined out hereE — Enclosing Scope
When a function is defined inside another function, the outer function's local scope becomes the inner function's *enclosing* scope. The inner function can read (but not, by default, assign to) names from the enclosing scope — this is the basis of closures.
def outer():
count = 0 # enclosing scope, from inner's point of view
def inner():
print(count) # reads count from the enclosing scope
inner()
outer() # 0G — Global Scope
Names assigned at the top level of a module (outside any function or class) live in that module's global scope, visible to every function defined in that module.
TAX_RATE = 0.08 # global
def price_with_tax(price):
return price * (1 + TAX_RATE) # reads the global TAX_RATE
print(price_with_tax(100)) # 108.0B — Built-in Scope
If a name isn't found in local, enclosing, or global scope, Python finally checks the built-in scope — names like len, print, range, Exception that are always available without importing anything.
print(len([1, 2, 3])) # 'print' and 'len' resolve from the built-in scope
import builtins
print(list(dir(builtins))[:5]) # the built-in scope is literally the builtins moduleName Resolution Order
When Python encounters a name being *read*, it searches Local, then Enclosing (if any, checking each enclosing function level outward), then Global, then Built-in — stopping at the first match. If no scope has that name, Python raises NameError.
x = "global x"
def outer():
x = "enclosing x"
def inner():
x = "local x"
print(x) # Local found first -> 'local x'
inner()
def outer_no_local():
x = "enclosing x"
def inner():
print(x) # no local x -> Enclosing found -> 'enclosing x'
inner()
outer() # local x
outer_no_local() # enclosing xShadowing
A name defined in an inner scope 'shadows' (temporarily hides) a same-named binding in an outer scope, for the duration of that inner scope. Shadowing a built-in name (like list, id, type, or sum) is legal but a common source of confusing bugs.
list = [1, 2, 3] # shadows the built-in 'list' type in this scope
print(list) # [1, 2, 3] — fine so far
# but now the built-in constructor is unreachable in this scope:
# new_list = list("abc") # TypeError: 'list' object is not callableAssignment Makes a Name Local — Unless Declared Otherwise
Python decides a name's scope for the *entire* function body at compile time, based on whether it's assigned anywhere in that function. Assigning to a name anywhere in a function makes that name local for the whole function — even on lines before the assignment — which causes UnboundLocalError if you try to read it before assigning.
This is a very common source of confusion: the error happens on the print line, but the *cause* is the assignment two lines later.
counter = 0
def increment():
print(counter) # UnboundLocalError!
counter = counter + 1 # this assignment makes 'counter' local to the WHOLE function
increment()global — Reaching the Module Scope
The global keyword tells Python that specific names, inside this function, refer to the module's global scope rather than creating a new local binding — allowing the function to reassign a global variable.
counter = 0
def increment():
global counter
counter += 1 # now this modifies the global 'counter', not a new local one
increment()
increment()
print(counter) # 2nonlocal — Reaching an Enclosing Scope
The nonlocal keyword (3.0+) works like global but targets the nearest enclosing function scope (not the module's global scope) — essential for a nested function to modify a variable from its enclosing function, such as in closures and counters.
nonlocal requires an existing binding in an enclosing *function* scope — it cannot reach all the way to module-global scope; use global for that.
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3Practical Usage
Prefer passing values in and returning values out over reaching for global/nonlocal — mutable shared state across functions makes code harder to test and reason about. nonlocal is most justified inside closures (like the counter above) where the whole point is encapsulated, persistent state.
Mental Model
Picture LEGB as four concentric rings, innermost to outermost: Local, Enclosing, Global, Built-in. Reading a name searches outward ring by ring and stops at the first hit. Plain assignment always writes into the innermost ring (Local) unless global/nonlocal explicitly redirects the write to an outer ring.
Quick Reference
LEGB read order: Local -> Enclosing -> Global -> Built-in
Without global/nonlocal: assignment always creates/uses a LOCAL name
global name : redirect assignment to the module's global scope
nonlocal name : redirect assignment to the nearest enclosing function scope
UnboundLocalError: reading a name before its local assignment in the same functionKey Takeaways
LEGB — Local, Enclosing, Global, Built-in — is the fixed order Python searches to resolve a name read.
Assigning to a name anywhere in a function body makes that name local for the entire function, even before the assignment line, which can cause UnboundLocalError.
global redirects assignment to the module's global scope; nonlocal redirects it to the nearest enclosing function scope.
Shadowing a built-in name is legal but can silently break code that expected the original built-in.
Prefer explicit parameters and return values over global/nonlocal state where practical, for clearer, more testable functions.
counter = 0 def increment(): counter += 1 # UnboundLocalError counter = 0 def increment(): global counter counter += 1 counter += 1 is shorthand for counter = counter + 1, an assignment, which makes counter local for the whole function — reading it before assigning fails unless 'global' is declared.
def make_counter(): count = 0 def increment(): count += 1 # UnboundLocalError return count return increment def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment
Without nonlocal, count += 1 inside increment() creates a new local 'count' instead of modifying the enclosing function's variable.
list = [3, 1, 2] sorted_list = list(sorted(list)) # TypeError: 'list' object is not callable items = [3, 1, 2] sorted_items = sorted(items)
Naming a variable 'list' shadows the built-in list type in that scope, making the constructor unreachable under that name for the rest of the scope.
What does LEGB stand for, and what order does Python search in?
Local, Enclosing, Global, Built-in — Python searches a name in that order, starting from the innermost (local) scope and working outward, stopping at the first match.
Why does assigning to a name inside a function make it local for the ENTIRE function body, even on lines before the assignment?
Because Python determines each name's scope at compile time by scanning the whole function body for assignments; if a name is assigned anywhere in the function, every reference to that name throughout the function is treated as local, causing UnboundLocalError if read before the assignment runs.
What is the difference between global and nonlocal?
global redirects a name's assignment to the module-level global scope; nonlocal redirects it to the nearest enclosing FUNCTION scope (used in nested functions/closures) — nonlocal cannot reach all the way to module scope.
What happens if you shadow a built-in name like len or list with your own variable?
The built-in becomes inaccessible under that name within the scope where it's shadowed; calling the original built-in functionality (e.g. list(...) as a constructor) then raises a TypeError because the name now refers to your variable instead.
Can a nested function read a variable from its enclosing function without using nonlocal?
Yes — reading doesn't require nonlocal, only assigning does; nonlocal is only needed when the nested function wants to REBIND (assign to) a name that lives in the enclosing scope.
Where does the built-in scope actually come from in CPython?
It is effectively the 'builtins' module (accessible via import builtins), which supplies names like print, len, and Exception without any explicit import needed.
Why is relying heavily on global variables generally discouraged in function design?
Because it creates hidden coupling between functions through shared mutable state, making code harder to test in isolation, harder to reason about (any function could change the value), and more prone to bugs from unexpected interactions.
Write a closure-based function make_accumulator() that returns a function which adds its argument to a running total and returns the new total, using nonlocal.
def make_accumulator(): total = 0 def add(amount): nonlocal total total += amount return total return add acc = make_accumulator() print(acc(10)) print(acc(5))
10 then 15 — nonlocal lets add() rebind the enclosing 'total' variable, so state persists across calls to the same closure.
What does this print, and why?
It raises UnboundLocalError: local variable 'x' referenced before assignment — the assignment x = 20 anywhere in f makes x local for the whole function, so the earlier print(x) tries to read a local that hasn't been assigned yet.
Write a function reset_cache() that uses the global keyword to reassign a module-level dict variable named cache to a brand-new empty dict, then returns it.
cache = {"a": 1, "b": 2} def reset_cache(): global cache cache = {} return cache print(reset_cache()) print(cache)
{} then {} — global is required here because the function REASSIGNS cache to a new object; without global, 'cache = {}' would just create a new local variable and leave the module-level cache untouched.
What does this print?
inner then outer — nonlocal in inner() reaches only the nearest enclosing scope (middle's x), changing it to 'inner'; outer's own x is untouched and still prints 'outer'.
x = 10
def f():
print(x)
x = 20
f()def outer():
x = 'outer'
def middle():
x = 'middle'
def inner():
nonlocal x
x = 'inner'
inner()
print(x)
middle()
print(x)
outer()