Skip to content
elephantoo

Scope, LEGB & closures

Lesson 12 of 38 14 min read

Local and global scope, the LEGB rule, UnboundLocalError, global/nonlocal and closures.


When you use a name like total, how does Python know which total you mean? The answer is scope: the region of code where a name is visible. Understanding scope explains a whole class of confusing bugs, and it unlocks closures — the mechanism behind decorators, callbacks and factories you'll use later.

Local and global scope#

A variable assigned inside a function is local to that function. It is created when the function is called and disappears when it returns. Variables assigned at the top level of a file are global to that module.

Python
x = "global x"

def demo():
    x = "local x"          # a NEW local variable; the global is untouched
    y = "only inside"
    print("inside:", x, "/", y)

demo()
print("outside:", x)
Output
inside: local x / only inside
outside: global x

Trying to use y outside the function raises NameError: name 'y' is not defined.

Functions can read global variables without doing anything special:

Python
TAX_RATE = 0.18

def with_tax(amount):
    return amount * (1 + TAX_RATE)   # reads the global

print(with_tax(100))
Output
118.0

Reading module-level constants like this is perfectly normal.

The LEGB rule#

When Python meets a name, it searches four scopes in order and uses the first match:

  1. Local — names assigned in the current function.
  2. Enclosing — names in any outer function(s), for nested functions.
  3. Global — names at the top level of the module.
  4. Built-in — Python's predefined names: len, print, range, ValueError…
Python
name = "global"

def outer():
    name = "enclosing"

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

outer()
print(name)
print(len("abc"))     # len found in built-ins
Output
local
enclosing
global
3

If no scope has the name, you get a NameError. This is also why shadowing built-ins is dangerous: a global named list or sum is found before the built-in.

Python
sum = 10               # shadows the built-in sum()
try:
    print(sum([1, 2, 3]))
except TypeError as e:
    print("Error:", e)
del sum                # remove the global; the built-in is visible again
print(sum([1, 2, 3]))
Output
Error: 'int' object is not callable
6

The UnboundLocalError trap#

Python decides whether a name is local when the function is compiled, not line by line. If a function assigns to a name anywhere in its body, that name is local everywhere in the body:

Python
count = 0

def increment():
    count += 1        # read count (local!) then assign — but it has no value yet
    return count

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

Fix 1 (usually best): pass in, return out

Python
def increment(count):
    return count + 1

count = 0
count = increment(count)
count = increment(count)
print(count)
Output
2

Fix 2: the global statement

Python
count = 0

def increment():
    global count      # "count refers to the module-level variable"
    count += 1

increment()
increment()
print(count)
Output
2

global works, but functions that secretly change globals are hard to test and reason about. Reserve it for rare cases like a module-level cache or configuration set once at start-up.

Mutating vs reassigning

You don't need global to mutate a global mutable object — only to reassign the name:

Python
seen = []

def remember(item):
    seen.append(item)     # mutation: no assignment to `seen`, so no problem

remember("a")
remember("b")
print(seen)
Output
['a', 'b']

Nested functions and closures#

Functions can be defined inside other functions. The inner function can see the outer function's variables — and it keeps them alive even after the outer function has returned. That combination is called a closure.

Python
def make_multiplier(factor):
    def multiply(x):
        return x * factor      # `factor` comes from the enclosing scope
    return multiply            # return the function itself, not a call

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10))
print(double.__closure__[0].cell_contents)
Output
20 30
2

Each call to make_multiplier creates a new factor and a new multiply that remembers it. Closures are a lightweight way to create configured functions — a "function factory".

nonlocal: changing enclosing variables

Just like global, reassigning an enclosing variable requires a declaration — nonlocal:

Python
def make_counter():
    count = 0

    def counter():
        nonlocal count
        count += 1
        return count

    return counter

c1 = make_counter()
c2 = make_counter()
print(c1(), c1(), c1())
print(c2())       # independent state
Output
1 2 3
1

Each counter has its own private count that no outside code can touch — a tiny taste of encapsulation without classes.

The late-binding gotcha

Closures capture variables, not values. A closure looks up the variable's value when it's called:

Python
funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])       # all see the final i

fixed = []
for i in range(3):
    fixed.append(lambda i=i: i)   # default arg captures the current value
print([f() for f in fixed])
Output
[2, 2, 2]
[0, 1, 2]

Scope and blocks: a Python quirk#

Unlike C or Java, if, for, while and with blocks don't create a new scope. A variable assigned inside a loop is still available afterwards:

Python
for i in range(3):
    last = i * 10

print(i, last)
Output
2 20

(Comprehensions do have their own scope, so the loop variable of [x for x in data] doesn't leak.)

Inspecting scopes#

locals() and globals() return dictionaries of the names currently in scope — handy for debugging and learning:

Python
def show():
    a, b = 1, 2
    print(sorted(locals()))

show()
Output
['a', 'b']

Worked example: a rate limiter using a closure#

Python
import time

def make_rate_limiter(max_calls, period):
    calls = []

    def allow():
        now = time.monotonic()
        # forget calls older than the period
        while calls and now - calls[0] > period:
            calls.pop(0)
        if len(calls) < max_calls:
            calls.append(now)
            return True
        return False

    return allow

allow = make_rate_limiter(max_calls=3, period=1.0)
print([allow() for _ in range(5)])
Output
[True, True, True, False, False]

The calls list lives in the enclosing scope, shared by every call to allow but hidden from the rest of the program.

Common mistakes#

  • UnboundLocalError from assigning to a global inside a function — pass and return values instead.
  • Overusing global — it makes code hard to test.
  • Shadowing built-ins (list, dict, id, input, sum).
  • Late binding in closures created in loops — capture the value with a default argument.

What's next#

That completes the beginner foundations! In the intermediate section we'll make functions more flexible with *args, **kwargs and lambdas.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In what order does Python look up a name (the LEGB rule)?

  2. 2.Why does this raise UnboundLocalError? count = 0 then def inc(): count += 1

  3. 3.What keyword lets an inner function reassign a variable of its enclosing function?

Finished reading?

Mark this lesson complete to track your progress.