Ever had a variable seem to "disappear," or a change inside a function that just doesn't stick once you're back outside it? That's python variable scope at work. This article covers local, global, and nonlocal scope in depth — including the LEGB rule that governs how Python actually resolves a name, and the UnboundLocalError that trips up even developers with real experience.
Introduction: What Is Variable Scope?
Scope determines where in a program a variable can be accessed, and how long it continues to exist. A variable created inside a function generally doesn't exist anywhere outside that function — and understanding exactly why (and when that rule has exceptions) is what this article is about.
The levels this article covers
Python resolves variable names according to four levels, often remembered by the acronym LEGB: Local, Enclosing, Global, Built-in. This article walks through local, global, and nonlocal (which relates to the enclosing scope) individually first, then ties them together with the full LEGB lookup order in Section 5.
Why this matters practically
Scope-related bugs are genuinely common, and genuinely confusing when you first hit them — a variable that seems to vanish the moment you leave a function, or an assignment inside a function that quietly does nothing to the variable you expected it to change. Understanding scope properly is what turns these from mysterious, frustrating bugs into predictable, easily diagnosed behavior.
Local Scope
Variables created inside a function stay inside it
A variable created inside a function exists only within that function's local scope — it's created when the function is called, and it disappears once the function finishes running.
def greet():
message = "Hello!" # local to greet()
print(message)
greet() # Hello!
print(message)
# NameError: name 'message' is not definedmessage simply doesn't exist outside greet() — trying to access it from the surrounding code raises a NameError, because as far as the rest of the program is concerned, that name was never defined anywhere it can see.
Parameters are local too
Function parameters follow exactly the same rule — they're local to the function they belong to, and inaccessible from outside it:
def add(a, b):
return a + b
add(3, 5)
print(a)
# NameError: name 'a' is not definedGlobal Scope and the global Keyword
Global variables are readable everywhere
A variable defined at the top level of a script or module — outside any function — is a global variable, and it's accessible (for reading) from anywhere in that module, including from inside functions:
count = 0
def show_count():
print(count) # reading a global variable works fine, no special keyword needed
show_count() # 0The catch: assignment creates a new local variable instead
This is where things get genuinely confusing for a lot of people. If you try to assign to a variable with the same name as a global one, inside a function, Python doesn't modify the global variable — it creates a brand-new local variable instead, shadowing the global one for the rest of that function:
count = 0
def increment():
count = count + 1 # this line causes a problem
print(count)
increment()
# UnboundLocalError: local variable 'count' referenced before assignmentThat error looks strange at first — count clearly exists as a global variable. But because the function contains an assignment to count anywhere in its body, Python treats count as local to that entire function, from the very first line — including the line that tries to read count before that local assignment has actually happened. The global count is invisible to this function the moment Python sees an assignment to that same name anywhere inside it.
Using the global keyword
To explicitly tell Python "I mean the global variable, not a new local one," use the global keyword:
count = 0
def increment():
global count
count = count + 1
print(count)
increment() # 1
increment() # 2
print(count) # 2 — the global variable was genuinely modifiedWith global count declared at the top of the function, Python now knows that every reference to count inside increment() refers to the module-level global variable — both for reading and for assignment — rather than creating a separate local shadow.
Nonlocal Scope and the nonlocal Keyword
The enclosing scope
When you nest one function inside another, the outer function's local variables form what's called the enclosing scope relative to the inner function. The inner function can read variables from that enclosing scope automatically, the same way any function can read a global variable:
def outer():
message = "Hello from outer"
def inner():
print(message) # reading an enclosing-scope variable — works fine
inner()
outer() # Hello from outerWhy nonlocal exists
Just like with global variables, trying to assign to an enclosing-scope variable from inside the nested function runs into the same shadowing problem — Python creates a new local variable inside inner() instead of modifying the outer function's variable:
def outer():
count = 0
def inner():
count = count + 1 # creates a new local 'count' inside inner(), doesn't touch outer's count
print(count)
inner()
print(count)
outer()
# UnboundLocalError — same underlying issue as the global exampleThe nonlocal keyword solves this exactly the way global solves the equivalent problem for module-level variables — it tells Python "modify the variable from the enclosing scope, don't create a new local one":
def outer():
count = 0
def inner():
nonlocal count
count = count + 1
print(count)
inner()
inner()
print(f"Final count: {count}")
outer()
# 1
# 2
# Final count: 2Key restrictions on nonlocal
nonlocal comes with two restrictions worth knowing:
It only works inside nested functions. You can't use
nonlocalat the top level of a module — there's no enclosing function scope to refer to at that level.The variable must already exist in the enclosing scope. Unlike
global, which can technically be used to create a brand-new global variable from inside a function,nonlocalrequires the variable to already be defined in the immediately enclosing function — it can't be used to invent a new binding that doesn't already exist there.
def outer():
def inner():
nonlocal new_var # new_var was never defined in outer() at all
new_var = 5
inner()
outer()
# SyntaxError: no binding for nonlocal 'new_var' foundThe LEGB Rule and Best Practices
Putting it all together
When Python needs to resolve a variable name, it searches through scopes in a specific, fixed order, commonly remembered as LEGB:
Local — the current function's own scope.
Enclosing — any outer function(s) this one is nested inside, checked from the innermost outward.
Global — the module's top-level scope.
Built-in — Python's built-in names, like
len,print, andrange, which are always available unless something more local shadows them.
Python checks each level in that exact order and uses the first match it finds, stopping there — it doesn't keep searching further out once a name is found.
Tracing an example
x = "global x"
def outer():
x = "enclosing x"
def inner():
x = "local x"
print(x) # Local scope wins — prints "local x"
inner()
print(x) # Enclosing scope — prints "enclosing x"
outer()
print(x) # Global scope — prints "global x"Three separate variables, all named x, each living in a different scope — and each print(x) resolves to whichever x is closest in the LEGB order from where that particular line of code sits.
Best practices
Avoid overusing global and nonlocal. While both are legitimate, necessary tools in specific situations, leaning on them heavily tends to make code genuinely harder to follow — a function that silently modifies state living outside itself is less predictable and harder to reason about (and harder to test in isolation) than one that takes explicit input and returns an explicit result. Where reasonably possible, prefer passing values in as parameters and getting results back out via return, rather than reaching into an outer scope to read or modify shared state directly.
# Less ideal — relies on modifying a global variable as a side effect
count = 0
def increment():
global count
count += 1
# Generally preferable — explicit input and output, no hidden shared state
def increment(count):
return count + 1
count = increment(count)Be aware that mutable objects don't need global/nonlocal for in-place modification. This is a genuinely important exception worth understanding well: if a global or enclosing-scope variable refers to a mutable object — a list or dictionary — you can modify its contents from an inner function without global or nonlocal at all, because you're not reassigning the variable itself, just mutating the object it already points to:
items = []
def add_item(item):
items.append(item) # no 'global' needed — this modifies the list in place, doesn't reassign 'items'
add_item("apple")
add_item("banana")
print(items) # ['apple', 'banana']This works because items.append(...) doesn't assign anything to the name items — it calls a method on the existing list object, mutating its contents directly. The UnboundLocalError situation from Section 3 only arises specifically from assignment to the variable name itself (count = count + 1), not from mutating an existing mutable object's contents through a method call.