Understanding Variable Scope

Variable scope is the answer to the question "which version of the name x does Python use here?". Python resolves names with the LEGB rule: it looks first in the Local scope (the current function), then the Enclosing function scope(s), then the Global (module) scope, and finally the Built-in scope that provides names such as print and len. The first hit wins; later scopes are not consulted.

Assignments and def/class statements create names in the local scope by default. This is why a function cannot assign to a module-level variable without global: the assignment creates a new local name that shadows the outer one. Merely reading a module-level name inside a function is fine because the lookup continues upward through E, G, and B without needing a declaration.

The global keyword declares that a name refers to the module scope for the remainder of the function. It is appropriate for a handful of cases — initialising a module-level cache, mutating a configuration dict — but routine use is a smell. A function that can only run in the context of specific module-level state is hard to reason about; pass the state as an argument instead, or wrap it in a class.

The nonlocal keyword does the same job for enclosing function scopes: it lets an inner function rebind a name in the nearest enclosing function. This is what powers small stateful closures such as counters and memoisation caches. Without nonlocal, the inner assignment would create a shadow in the inner scope and leave the outer name unchanged.

Function arguments are local names; mutating a mutable argument (appending to a list, adding to a dict) does change the object in place, but rebinding the name (items = []) does not affect the caller's name. Understanding the difference between "modifying the object" and "rebinding the name" explains nine out of ten "why is my variable still the old value?" confusions.

The LEGB lookup rule in practice

Inside a function, you can freely read names from the enclosing, global or built-in scopes. You cannot shadow a built-in by accident in a safe way: writing list = [1, 2] at module level hides list as a type for the rest of the module, and is one of the more painful bugs to diagnose. Pick variable names that do not collide with built-ins.

Closures with nonlocal

A closure is an inner function that captures names from an enclosing function. Reading the captured names is automatic; rebinding them requires nonlocal. Closures are the cleanest way to make small stateful generators and counters without defining a full class.

The tools and rules for navigating Python's scope model.

ToolPurpose
LEGB rule
language rule
Local → Enclosing → Global → Built-in name resolution order.
global
keyword
Binds a name to the module scope inside a function.
nonlocal
keyword
Binds a name to the nearest enclosing function scope.
locals()
built-in
Dict of the current local namespace; read-mostly.
globals()
built-in
Dict of the module namespace; mutating it works (but is rare).
__builtins__
module
Holds print, len, range, etc.; the B in LEGB.
vars()
built-in
Returns the __dict__ of a module, class or instance.
closure
concept
An inner function that retains access to enclosing names.

Understanding Variable Scope code example

The example shows name resolution across four scopes and uses nonlocal to build a small counter closure.

# Lesson: Understanding Variable Scope
# Goal: make LEGB, global and nonlocal concrete with one small script.


# G: module-level (global) names
VERSION = "1.0"      # read-only by convention
click_count = 0       # will be mutated via `global`


def click() -> None:
    '''Increment the module-level counter.'''
    global click_count
    click_count += 1


def make_counter(start: int = 0):
    '''Return a closure that increments and returns a hidden counter.'''
    value = start          # E: enclosing scope

    def bump(step: int = 1) -> int:
        nonlocal value     # we want to rebind the outer value, not shadow it
        value += step
        return value

    return bump


def demo_shadowing() -> list[str]:
    '''Show how L shadows G without affecting the module-level name.'''
    click_count = 999      # L: local, unrelated to the module-level name
    return [f"L local={click_count}", f"G module={globals()['click_count']}"]


# --- main script ---------------------------------------------------------
for _ in range(3):
    click()
print("clicks:", click_count)       # from G, updated by click()

counter = make_counter(10)
print("counter:", counter(), counter(), counter(3))  # -> 11 12 15

print("shadow demo:", demo_shadowing())
print("VERSION (B/G lookup):", VERSION, "len is a builtin:", len([1, 2, 3]))

Walk through each scope level:

1) VERSION and click_count live in G (module scope).
2) click() uses `global` to rebind click_count; reading would also work.
3) make_counter() returns a closure; bump() uses `nonlocal` on value.
4) demo_shadowing() shows that a local assignment never affects G.

Two short snippets that contrast LEGB rules against intuition.

# Example A: mutation vs rebinding of a mutable argument
def append_one(items: list[int]) -> None:
    items.append(1)           # mutates the caller's list
    items = [999]             # rebinds the LOCAL name only

data = []
append_one(data)
print(data)   # -> [1], NOT [999]

# Example B: a per-call memoisation cache using nonlocal
def memoise(func):
    cache = {}
    def wrapper(*args):
        if args in cache:
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result
    return wrapper

@memoise
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(30))

These assertions lock in the scope rules.

# Counter closure keeps its own state
c = make_counter()
assert c() == 1
assert c() == 2
assert c(5) == 7
# A second counter is independent (different closure, different `value`)
c2 = make_counter(100)
assert c2() == 101
# Local assignment in demo_shadowing() does not touch the module name
before = click_count
demo_shadowing()
assert click_count == before

Running the script prints (three clicks plus closure results):

clicks: 3
counter: 11 12 15
shadow demo: ['L local=999', 'G module=3']
VERSION (B/G lookup): 1.0 len is a builtin: 3