Type something to search...
Python Scope Explained: LEGB, global, and nonlocal

Python Scope Explained: LEGB, global, and nonlocal

Every time Python sees a name like count or len, it has to figure out which variable that name refers to. Most of the time the answer is obvious and you never think about it. Then one day you add count += 1 inside a function and Python throws UnboundLocalError at a variable that is very clearly defined three lines above. Or a loop variable survives after the loop, while a comprehension variable doesn't.

All of this follows from a small set of scoping rules. Python decides where a name lives when it compiles the function, not when the line runs, and it searches a fixed sequence of scopes known as LEGB. Once you know those two facts, the confusing errors stop being confusing.

This post covers the four scopes, how Python decides a name is local, the UnboundLocalError trap, the global and nonlocal keywords and when they're appropriate, which blocks do and don't create a scope, and a few edge cases with classes and closures.

The Four Scopes: LEGB

When Python looks up a name, it searches these scopes in order and stops at the first match:

  1. Local: names assigned inside the current function.
  2. Enclosing: names in any enclosing function, from the innermost outward. This only exists for nested functions.
  3. Global: names assigned at the top level of the current module.
  4. Built-in: names Python provides everywhere, like len, print, ValueError, and range.

If the name isn't found in any of them, you get a NameError.

Here's all four in one example:

x = "global"


def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)

    inner()
    print(x)


outer()
print(x)
local
enclosing
global

Each function has its own x. Assigning to x inside inner() creates a new local variable; it doesn't touch the x in outer() or the global one. Each print(x) finds the nearest one.

Remove the local assignment and lookup continues outward:

def outer2():
    msg = "from outer"

    def inner():
        print(msg)

    inner()


outer2()
from outer

inner() has no local msg, so Python finds it in the enclosing scope. And built-ins are just the last stop on the same search, which is why len works anywhere:

import builtins


def show_len() -> int:
    return len("abc")


print(show_len(), "len" in dir(builtins))
3 True

Shadowing Built-ins

Since built-ins are searched last, any local or global with the same name hides them:

def shadow():
    list = [1, 2]
    return list


print(shadow(), list("ab"))
[1, 2] ['a', 'b']

Inside shadow(), list is a local variable; outside, the built-in is untouched. It's harmless here, but shadowing list, id, type, input, or filter in a larger function means you can't call the built-in later in that function. Linters like Ruff flag this (the A rules from flake8-builtins), and it's worth turning that check on.

Python Decides "Local" at Compile Time

This is the rule that explains almost every scope surprise:

If a name is assigned anywhere in a function body, it is local to that entire function, unless declared global or nonlocal.

"Assigned" includes more than =. Any of these make a name local:

  • x = ..., x += ..., and other augmented assignments
  • for x in ...
  • import x and from m import x
  • def x(...) and class x:
  • with ... as x and except ... as x
  • Unpacking targets, x := ..., and case capture patterns

Python scans the whole function body when compiling it. It doesn't matter whether the assignment comes before or after the line that reads the name, or whether it ever runs at all.

You can see the result of that decision on the function's code object:

def g():
    a = 1
    b = 2
    return locals()


print(g())
print(g.__code__.co_varnames)
{'a': 1, 'b': 2}
('a', 'b')

co_varnames lists the local variable names that the compiler fixed in advance.

The UnboundLocalError Trap

Now the classic error makes sense:

count = 0


def increment():
    count += 1


increment()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

count += 1 is an assignment, so count is local to increment(). To compute count + 1, Python reads the local count, which hasn't been given a value yet. It never looks at the global count, because the compiler already decided the name is local.

The assignment doesn't even have to come first:

total = 10


def report():
    print(total)
    total = 0


report()
UnboundLocalError: cannot access local variable 'total' where it is not associated with a value

Without the total = 0 line at the bottom, print(total) would happily print 10. With it, total is local everywhere in the function, including the print line above it.

UnboundLocalError is a subclass of NameError. The message in Python 3.11+ is much clearer than the old "referenced before assignment" wording, and it points at exactly this situation: a local variable read before any value was bound to it.

There are two fixes, depending on what you meant:

  1. You meant to use a separate local variable. Rename it so it doesn't collide with the outer name.
  2. You meant to modify the outer variable. Declare it with global or nonlocal, or better, restructure the code so the function returns a value instead.

The global Keyword

global name tells Python that, inside this function, name refers to the module-level variable. Reads and assignments both go to the global:

counter = 0


def bump() -> None:
    global counter
    counter += 1


bump()
bump()
print(counter)
2

global can even create a module-level variable that didn't exist before:

def f():
    global created_here
    created_here = "hi"


f()
print(created_here)
hi

That last example is also a good illustration of why global is usually discouraged. Code elsewhere in the module now depends on f() having been called first, and nothing in the source makes that dependency visible.

You Don't Need global to Mutate

global is only needed to rebind a global name. Calling a method that mutates a global object is just a read of the name, so no declaration is required:

items = []


def add(item):
    items.append(item)


add(1)
print(items)
[1]

items.append() looks up items (finding the global) and mutates the list. Nothing is assigned to the name items, so it never becomes local. This distinction between rebinding a name and mutating an object comes up constantly; Python Variables and Mutability covers it in detail.

Alternatives to global

Module-level mutable state makes code harder to test and reason about. Common alternatives:

  • Return values and let the caller decide what to store. counter = bump(counter) is explicit.
  • Pass state in as an argument, or bundle it into an object with methods.
  • Use a class when several functions share state.
  • Keep true constants global (configuration, compiled regexes, lookup tables). Reading globals is fine; it's rebinding them from functions that causes trouble.

The nonlocal Keyword

nonlocal is the nested-function counterpart of global. It tells Python that a name refers to a variable in the nearest enclosing function scope, so you can rebind it:

def make_counter():
    count = 0

    def step() -> int:
        nonlocal count
        count += 1
        return count

    return step


c = make_counter()
print(c(), c(), c())

c2 = make_counter()
print(c2())
1 2 3
1

Each call to make_counter() creates a fresh count, and the returned step() function keeps updating its own copy. That's a closure: an inner function that remembers variables from the scope it was defined in. Without nonlocal, count += 1 would make count local to step() and you'd get the same UnboundLocalError as before:

def make_counter_bad():
    count = 0

    def step():
        count += 1
        return count

    return step


make_counter_bad()()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

nonlocal has stricter rules than global:

  • The name must already exist in an enclosing function. It can't create a new variable, and it never refers to globals.
  • It only works inside nested functions.
def f():
    nonlocal zz
SyntaxError: no binding for nonlocal 'zz' found

Just as with global, mutating an enclosing object doesn't need nonlocal:

def outer4():
    data = [1]

    def inner():
        data.append(2)

    inner()
    return data


print(outer4())
[1, 2]

Closures are the main reason nonlocal exists, and they're powerful enough to deserve their own post: see Closures in Python.

Closures Look Up Variables Late

Enclosing variables are looked up when the inner function runs, not when it's defined. The inner function sees the variable's current value:

def outer3():
    v = 1

    def inner():
        return v

    v = 2
    return inner


print(outer3()())
2

The same rule means an inner function can refer to a name assigned later in the enclosing function, as long as it's assigned by the time the inner function is called:

def lookup():
    def inner():
        return later

    later = "defined after inner"
    return inner()


print(lookup())
defined after inner

Late binding is behind a well-known bug with functions created in a loop:

def make_multipliers():
    return [lambda v: v * i for i in range(3)]


print([m(10) for m in make_multipliers()])
[20, 20, 20]

All three lambdas share the same i, and by the time they're called the loop has finished with i == 2. The standard fix is to bind the current value as a default argument, which is evaluated at definition time:

def make_multipliers2():
    return [lambda v, i=i: v * i for i in range(3)]


print([m(10) for m in make_multipliers2()])
[0, 10, 20]

functools.partial() is a cleaner alternative when the function already exists.

What Does and Doesn't Create a Scope

Coming from languages with block scope, it's surprising how few constructs create a new scope in Python. Only modules, functions (including lambdas), classes, and comprehensions do. Blocks like if, for, while, with, and try don't:

for i in range(3):
    pass
print(i)

if True:
    inside_if = "visible"
print(inside_if)
2
visible

The loop variable survives with its last value, and a variable assigned inside an if is visible after it. That's by design; it means you can assign in a branch and use the value afterward.

There are two exceptions worth knowing.

Comprehension variables don't leak. A comprehension runs in its own scope (since Python 3.12 it's inlined for speed, but the isolation is preserved):

print([n for n in range(3)])
print(n)
[0, 1, 2]
NameError: name 'n' is not defined

Exception variables are deleted after the except block. Python implicitly runs del err at the end of the handler to avoid a reference cycle between the exception and the stack frame:

try:
    1 / 0
except ZeroDivisionError as err:
    pass

print(err)
NameError: name 'err' is not defined

If you need the exception later, assign it to another name inside the block: saved = err.

Class Bodies Are Not an Enclosing Scope

Class bodies have their own namespace, but it's not part of the LEGB chain for functions defined inside the class. Methods can't see class attributes as bare names:

class A:
    y = 5

    def method(self):
        return y

    def method2(self):
        return self.y


print(A().method2())
print(A().method())
5
NameError: name 'y' is not defined

Inside method(), the lookup goes local, then (no enclosing function), then global, then built-in, and never checks the class body. You have to go through self.y or A.y. This is deliberate: it keeps attribute access explicit and makes inheritance work, since self.y can find an override on a subclass. The same rule causes the class-body comprehension gotcha described in List, Dict, and Set Comprehensions in Python.

Inspecting Scopes

A few tools help when you're debugging scope issues:

  • locals() returns the current local namespace as a dict. In a function, treat it as a snapshot; since Python 3.13 (PEP 667), each call returns an independent snapshot, and writing to it never changes the real local variables.
  • globals() returns the module's global namespace as a live dict.
  • func.__code__.co_varnames lists a function's local names; co_freevars lists the enclosing variables a closure uses.
def closure_vars():
    a = 1

    def inner():
        return a

    return inner


print("x" in globals())
print(closure_vars().__code__.co_freevars)
True
('a',)

(Here x is the global from the first example in the same script.) You rarely need these in application code, but they're useful for understanding what the compiler decided.

Quick Reference

SituationWhat happensFix
Read an outer variableFound through LEGBNothing needed
Assign to a name in a functionCreates a localIntended in most cases
Read then assign the same nameUnboundLocalErrorRename, global/nonlocal, or return a value
Mutate an outer object (.append())Works, no declarationNothing needed
Rebind a module variableNeeds globalPrefer returning values
Rebind an enclosing function's variableNeeds nonlocalCommon in closures
Use a class attribute in a methodNameErrorUse self.attr or ClassName.attr
Variable from if/for blockStill visible afterBy design

Conclusion

Python resolves names through four scopes, local, enclosing, global, and built-in, and decides at compile time which names are local to each function: anything assigned anywhere in the body. That single rule explains UnboundLocalError, why mutating an outer list works while rebinding it doesn't, and why moving one assignment can change how an earlier line behaves.

Use global and nonlocal when you genuinely need to rebind an outer name, with nonlocal mostly reserved for closures and global best kept rare. Remember that blocks don't create scopes, comprehensions and except ... as variables are the exceptions, and class bodies aren't visible from methods. With LEGB in mind, you can predict where any name will be found before you run the code.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading