Python Scope
In Python, scope determines where in your code a name — a variable, function, or class — can be seen and used. Creating a variable doesn’t just store a value; Python also records where that name lives, and that location decides which parts of your program are allowed to read or change it. Understanding scope explains why a variable defined inside a function vanishes outside of it, why two different functions can each use a local variable named x without conflict, and why some assignments inside a function silently fail to change the value you expected.
How It Works: The LEGB Rule
Every name lookup in Python follows a fixed search order known as the LEGB rule: Local, Enclosing, Global, Built-in. When your code references a name, Python searches these four namespaces in order and stops at the first match.
- Local (L) — names assigned anywhere inside the current function. This namespace is created fresh every time the function is called and destroyed when it returns.
- Enclosing (E) — names in the local scope of any outer function that contains the current function (relevant for nested functions and closures).
- Global (G) — names assigned at the top level of the current module (the
.pyfile). There is one global namespace per module. - Built-in (B) — names Python provides automatically, such as
print,len,range, andException, defined in thebuiltinsmodule.
Internally, each of these scopes is backed by a namespace that behaves like a dictionary mapping names (strings) to objects. A function call creates a new local namespace (technically a frame); a module has its own global namespace; and the built-in namespace is loaded once when the interpreter starts. When you write x = 5 inside a function, Python is really doing the equivalent of inserting "x": 5 into that function’s local namespace — nothing outside the function is touched unless you explicitly say otherwise with global or nonlocal.
A crucial and often surprising detail: Python decides whether a name is local to a function at compile time, not while the function is running. The compiler scans the entire function body first; if it finds an assignment to a name anywhere in that function (even on a line that executes later, or never), it marks that name as local for the whole function. This is the root cause of one of the most common scope bugs in Python, covered later in Common Mistakes.
Syntax
Most of the time you don’t need any special syntax — reading an outer-scope variable just works. You only need a keyword when you want to rebind (assign to) a name that lives in an outer scope from inside a function.
global name1, name2
nonlocal name1, name2
| Scope | Description | How to modify it from a nested function |
|---|---|---|
| Local | Names assigned inside the current function | No keyword needed — it’s already local |
| Enclosing | Local names of an outer (non-global) function | nonlocal name |
| Global | Names assigned at module top level | global name |
| Built-in | Names Python provides, like print or len |
Not modifiable this way — reassigning shadows it instead |
globalmust appear before the name is used or assigned in that function; it tells Python “this name refers to the module-level variable, not a new local one.”nonlocalworks the same way but targets the nearest enclosing function scope — it requires that an enclosing function actually assigns that name somewhere; it cannot reach all the way to the global scope.- You can list multiple names separated by commas in a single
globalornonlocalstatement. - Neither keyword is needed to read an outer variable, and neither is needed to mutate a mutable object you already have a reference to (more on this below).
Examples
Example 1: The LEGB rule in action
x = "global x"
def outer():
y = "enclosing y"
def inner():
z = "local z"
print(x)
print(y)
print(z)
inner()
outer()
Output:
global x
enclosing y
local z
inner() doesn’t define x or y itself, so Python walks outward: z is found locally, y is found in the enclosing scope of outer, and x is found in the module’s global scope. All three lookups succeed without any special keywords because they are all reads, not assignments.
Example 2: Rebinding a global variable with global
total_score = 0
def add_points(points):
global total_score
total_score += points
print(f"Total score is now {total_score}")
add_points(10)
add_points(25)
print(f"Final total: {total_score}")
Output:
Total score is now 10
Total score is now 35
Final total: 35
Without the global total_score line, total_score += points would try to create a brand-new local variable called total_score and immediately fail, because += requires reading the current value before writing the new one, and no local total_score exists yet. Declaring it global tells Python to use — and update — the module-level variable instead.
Example 3: Closures with nonlocal
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c1 = make_counter()
c2 = make_counter()
print(c1())
print(c1())
print(c1())
print(c2())
Output:
1
2
3
1
Each call to make_counter() creates a brand-new enclosing scope with its own count variable. The inner counter function is a closure — it “closes over” that specific count and keeps a live reference to it even after make_counter has returned. Because counter rebinds count (via +=), it needs nonlocal to tell Python that count belongs to the enclosing scope rather than being a new local variable. c1 and c2 are independent counters because each closure captures a different count.
Example 4: Mutating vs. rebinding
totals = []
def add_total(value):
totals.append(value)
add_total(10)
add_total(25)
print(totals)
Output:
[10, 25]
This works without global, which surprises many learners. totals.append(value) does not assign to the name totals — it looks up totals (a read, found via LEGB in the global scope), then calls a method on the list object it finds, mutating that object in place. Only statements like totals = totals + [value] or totals = [] would rebind the name totals itself, and those would require global inside a function.
Under the Hood: Namespaces and Static Scoping
You can inspect namespaces directly with the built-in functions locals() and globals(), which return dictionary-like views of the current local and global namespaces:
name = "Ada"
def describe():
role = "programmer"
print("Local names:", locals())
describe()
print("Is 'name' in globals()?", "name" in globals())
Output:
Local names: {'role': 'programmer'}
Is 'name' in globals()? True
Notice that describe()‘s local namespace contains only role — parameters and locally assigned names — not name, which lives in the module’s global namespace instead.
The most important internal detail to remember is that Python’s compiler determines a variable’s scope statically, by scanning the source of each function before it ever runs. If any line in a function assigns to a name (x = ..., x += ..., for x in ..., etc.), that name is classified as local for the entire function body — including lines that come before the assignment. This is why referencing a variable and then assigning to it later in the same function can raise UnboundLocalError, even though a global variable with the same name exists. Python already decided, before running a single line, that the name belongs to the local scope.
Common Mistakes
Mistake 1: Assigning to a name makes it local for the whole function
This is the single most common scope bug. Consider a function that tries to increment a module-level counter without declaring it global:
counter = 0
def increment():
counter += 1 # UnboundLocalError: 'counter' is treated as local
return counter
print(increment())
Because counter is assigned inside increment (via +=), Python treats it as a local variable for the whole function — so the read on the right-hand side of += fails with UnboundLocalError, since that local counter hasn’t been given a value yet. The fix is to declare the name global before using it:
counter = 0
def increment():
global counter
counter += 1
return counter
print(increment())
print(increment())
Output:
1
2
Mistake 2: Mutable default arguments and scope lifetime
Default argument values are evaluated only once, when the function is defined, and the resulting object is stored and reused on every call that doesn’t override it. This means a mutable default (like a list) behaves like shared, persistent state across calls — a scope trap disguised as a parameter:
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("banana"))
Output:
['apple']
['apple', 'banana']
The second call reuses the exact same list object from the first call instead of starting fresh, because the default was created once and lives on as long as the function object exists. The standard fix is to default to None and create a new list inside the function body each call:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("banana"))
Output:
['apple']
['banana']
Best Practices
- Prefer passing values in as arguments and returning results over relying on
globalto communicate between functions — it keeps functions testable and predictable. - Reserve
nonlocalfor closures that intentionally maintain state between calls, such as counters, memoizers, or simple decorators. - Remember that only rebinding a name requires
global/nonlocal— reading an outer variable, or mutating a mutable object you already reference, does not. - Avoid shadowing built-in names like
list,dict,str,id, ortypeas variable names; doing so hides the built-in for the rest of that scope. - Keep functions small so their local namespace stays easy to reason about — deeply nested closures with several
nonlocalvariables are hard to trace. - Never use a mutable object (list, dict, set) as a default argument value; default to
Noneand create the object inside the function body instead. - Use
locals()andglobals()for debugging and introspection only — not as a way to dynamically create or discover variable names in normal code.
Practice Exercises
- Write a function
make_multiplier(factor)that returns a new function which multiplies any number passed to it byfactor, using a closure that simply reads the enclosingfactor(nononlocalrequired). Createdouble = make_multiplier(2)and confirmdouble(5)returns10. - A colleague wrote a function that increments a module-level variable
visitseach time a page is “viewed,” but calling it raisesUnboundLocalError. Rewrite the function so it correctly updatesvisits, and in one sentence explain why the original version failed. - Write a function
id_generator()that returns a new function producing an incrementing integer starting at 1 (1, 2, 3, …) each time it’s called, using anonlocalcounter. Verify that callingid_generator()a second time starts a fresh sequence back at 1, independent of the first one.
Summary
- Python resolves names using the LEGB rule: Local → Enclosing → Global → Built-in, stopping at the first match.
- Scope is determined statically at compile time based on where a name is assigned in the source code, not on the order statements actually execute at runtime.
- Assigning to a name anywhere inside a function makes it local for the entire function unless you declare it
globalornonlocalfirst. globallets a function rebind a module-level variable;nonlocallets a nested function rebind a variable from an enclosing (non-global) function.- Mutating a mutable object (e.g.
list.append) is a read, not an assignment, so it never requiresglobalornonlocal. - Closures capture enclosing-scope variables by reference, which is what makes patterns like counters and function factories possible.
