Zum Hauptinhalt springen

Chapter 4 — Scope, Namespaces, LEGB, and Closures

1. Opening problem

Predict:

message = "global"


def outer():
message = "outer"

def inner():
print(message)

inner()


outer()

The function prints:

outer

Why?

Python resolves names using lexical scopes. A common model is LEGB:

  1. Local
  2. Enclosing
  3. Global
  4. Built-in

This chapter goes beyond memorizing LEGB. It explains:

  • how names are classified;
  • when local scope is created;
  • why assignment changes lookup behaviour;
  • how closures capture bindings;
  • why late binding causes bugs;
  • how global and nonlocal work;
  • why default arguments are evaluated at definition time;
  • how modules and class bodies create namespaces.

2. Namespace versus scope

A namespace is a mapping from names to objects.

Examples:

  • module namespace;
  • function local namespace;
  • class namespace;
  • built-in namespace.

A scope is a region of code where a namespace is directly accessible through normal name lookup.

These concepts are related but not identical.

For practical reasoning:

  • namespace answers: "Where are names stored?"
  • scope answers: "Where can this code resolve the name?"

3. LEGB lookup

label = "global"


def outer():
label = "enclosing"

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

inner()


outer()

The local label wins.

Remove it:

def inner():
print(label)

Now Python finds the enclosing name.

Remove the enclosing name:

def outer():
def inner():
print(label)

inner()

Now Python finds the global name.

If no global name exists, Python checks built-ins:

print(len([1, 2, 3]))

len is found in the built-in namespace.


4. Assignment determines local classification

Consider:

count = 10


def increment():
print(count)
count = count + 1

Calling increment() raises UnboundLocalError.

Why?

Because assignment to count anywhere in the function body makes count a local name unless declared otherwise.

Python conceptually interprets:

count = count + 1

as:

  1. read local count;
  2. add one;
  3. assign local count.

But the local name has not yet been bound when print(count) runs.

This surprises developers who expect lookup to use the global value before assignment.


5. global

count = 10


def increment():
global count
count += 1

Now the function changes the module-level binding.

Use global cautiously.

Problems with global mutation:

  • hidden dependencies;
  • difficult tests;
  • order-dependent behaviour;
  • concurrency hazards;
  • tight coupling;
  • unclear ownership.

Often a return value is better:

def increment(count: int) -> int:
return count + 1

Or encapsulate state:

from dataclasses import dataclass


@dataclass
class Counter:
value: int = 0

def increment(self) -> None:
self.value += 1

6. nonlocal

nonlocal refers to a binding in an enclosing function scope.

def create_counter():
count = 0

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

return increment


counter = create_counter()

print(counter())
print(counter())
print(counter())

Output:

1
2
3

The inner function closes over the count binding.

Without nonlocal, assignment would create a new local count and fail when trying to read it first.


7. Closures

A closure is a function that retains access to bindings from an enclosing lexical scope after the enclosing function has returned.

def multiplier(factor: int):
def multiply(value: int) -> int:
return value * factor

return multiply


double = multiplier(2)
triple = multiplier(3)

print(double(10))
print(triple(10))

The returned functions retain different factor bindings.

Closures are useful for:

  • configuration;
  • callbacks;
  • decorators;
  • dependency injection;
  • small stateful functions;
  • function factories;
  • adapters.

8. Closure introspection

def multiplier(factor: int):
def multiply(value: int) -> int:
return value * factor

return multiply


double = multiplier(2)

print(double.__closure__)
print(double.__code__.co_freevars)

You can inspect closure details for learning and debugging. Do not rely heavily on such internals in business logic.


9. Late binding

Predict:

functions = []

for number in range(3):
functions.append(lambda: number)

print([function() for function in functions])

Output:

[2, 2, 2]

The lambdas do not store the current numeric value independently. They refer to the same number binding, which contains 2 when the functions are later called.

This is called late binding because the value is looked up when the function executes.

Solution with a default argument

functions = []

for number in range(3):
functions.append(lambda number=number: number)

Default argument expressions are evaluated when the function is created.

Each lambda receives its own default value.

Solution with a factory

def capture(number: int):
def get_number() -> int:
return number

return get_number


functions = [capture(number) for number in range(3)]

The factory creates a new enclosing scope for each call.


10. TypeScript comparison: var and let

JavaScript developers may remember:

const functions = [];

for (var number = 0; number < 3; number++) {
functions.push(() => number);
}

This produces repeated final values because var is function-scoped.

Using let creates a new loop binding per iteration:

for (let number = 0; number < 3; number++) {
functions.push(() => number);
}

Python loop variables do not automatically create a new closure binding per iteration. Use a default argument or factory when value capture is required.


11. Default arguments and scope timing

rate = 0.19


def calculate_tax(amount: float, tax_rate: float = rate) -> float:
return amount * tax_rate


rate = 0.25

print(calculate_tax(100))

Output:

19.0

The default value was evaluated when the def statement executed.

This explains both:

  • useful value capture;
  • mutable default argument bugs.

Default expressions are not reevaluated for every call.


12. Comprehension scope

In modern Python, comprehension loop variables have their own scope:

number = 100
squares = [number * number for number in range(3)]

print(number)

Output:

100

The comprehension variable does not overwrite the surrounding number.

However, a generator expression executes lazily:

factor = 2
values = (number * factor for number in range(3))

factor = 10

print(list(values))

Output:

[0, 10, 20]

The generator looks up factor during iteration.

This is another late-binding effect.

To freeze the current factor, introduce a factory or transform eagerly.


13. Class-body scope

A class statement executes and creates a namespace:

class Settings:
timeout = 5
retries = 3

After creation, the names become class attributes.

Class scope behaves differently from function-enclosing scope.

For example, methods do not automatically close over class-body names:

class Example:
value = 10

def show(self):
print(value)

Calling show raises NameError.

Use:

class Example:
value = 10

def show(self):
print(self.value)

or:

class Example:
value = 10

@classmethod
def show(cls):
print(cls.value)

Attribute lookup is not the same as lexical name lookup.


14. Modules as namespaces

A Python module is an object with a namespace.

Suppose settings.py contains:

DEBUG = False
TIMEOUT_SECONDS = 5

Import the module:

import settings

print(settings.DEBUG)

This is often clearer than importing several names directly:

from settings import DEBUG, TIMEOUT_SECONDS

Module qualification helps communicate ownership:

settings.DEBUG
settings.TIMEOUT_SECONDS

Imports also create bindings in the importing module.

import settings

binds the name settings.

from settings import DEBUG

binds the name DEBUG to the imported object.


15. Imported name rebinding

Suppose:

# settings.py
DEBUG = False

and:

# app.py
from settings import DEBUG

DEBUG = True

This rebinds DEBUG in app.py. It does not change settings.DEBUG.

Compare:

import settings

settings.DEBUG = True

This mutates the attribute in the imported module object.

The distinction is another example of names and bindings.


16. Built-in shadowing

Avoid:

list = [1, 2, 3]
str = "hello"
id = 42

These names shadow built-ins.

Later:

list("abc")

fails because list now refers to a local list object.

Use descriptive alternatives:

numbers = [1, 2, 3]
message = "hello"
user_id = 42

Shadowing is legal but often confusing.


17. Closures versus classes

A closure can model small state:

def create_counter():
value = 0

def increment():
nonlocal value
value += 1
return value

return increment

A class can model the same concept:

class Counter:
def __init__(self) -> None:
self._value = 0

def increment(self) -> int:
self._value += 1
return self._value

Use a closure when:

  • state is small;
  • only one or two operations are needed;
  • the object identity need not be inspected;
  • implementation should remain private.

Use a class when:

  • several operations exist;
  • state needs inspection;
  • lifecycle is meaningful;
  • inheritance or protocols matter;
  • the object needs a clear representation;
  • testing benefits from explicit structure.

18. Dependency injection with closures

from collections.abc import Callable


def create_user_loader(
fetch_row: Callable[[int], dict[str, object] | None],
):
def load_user(user_id: int) -> dict[str, object]:
row = fetch_row(user_id)

if row is None:
raise LookupError(f"User {user_id} not found")

return row

return load_user

The returned function retains access to fetch_row.

Test:

def fake_fetch_row(user_id: int):
if user_id == 1:
return {"id": 1, "name": "Mika"}

return None


load_user = create_user_loader(fake_fetch_row)

assert load_user(1)["name"] == "Mika"

This is lightweight dependency injection without a container.


19. Decorator preview

Closures power decorators:

from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def log_calls(function: Callable[P, R]) -> Callable[P, R]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {function.__name__}")
return function(*args, **kwargs)

return wrapper

wrapper closes over function.

This idea will be explored deeply in the decorators chapter.


20. Common scope mistakes

Mistake 1: Assuming assignment uses a global value first

total = 0


def add(value):
total += value

This fails without global, because total is classified as local.

Mistake 2: Overusing global

A global variable may be replaced by:

  • a parameter;
  • a return value;
  • a state object;
  • a configuration object;
  • a class instance;
  • a dependency passed to a factory.

Mistake 3: Late-bound callbacks

handlers = []

for event_name in event_names:
handlers.append(lambda: handle(event_name))

Every handler may use the final event_name.

Mistake 4: Shadowing built-ins

input = request.json

This shadows the built-in input function. It may be acceptable in a narrow scope, but descriptive names are safer.

Mistake 5: Confusing attributes with lexical names

Inside a method, use self.value or type(self).value, not bare value, unless a lexical binding exists.


21. Debugging scope

Useful functions:

print(locals())
print(globals())

Inside a function:

def inspect_scope(value):
local_message = "hello"
print(locals())

Use these mainly for debugging and education.

You can also inspect code metadata:

print(function.__code__.co_varnames)
print(function.__code__.co_freevars)
print(function.__code__.co_cellvars)

A practical debugging method:

  1. Identify where the name is used.
  2. Search for assignments in the same function.
  3. Determine whether it is local, enclosing, global, or built-in.
  4. Check whether a closure looks up the value later.
  5. Check whether a default expression captured an earlier value.
  6. Rename shadowing variables.
  7. Replace hidden shared state with explicit dependencies.

22. English vocabulary

TermMeaning
namespacea mapping from names to objects
scopea region where names are directly accessible
enclosing scopethe scope of an outer function
shadowinghiding another name with the same identifier
closurea function retaining access to enclosing bindings
late bindingresolving a value when a function executes
captureretaining access to a value or binding
rebindingassigning a name to a different object
lexicaldetermined by the written structure of code
free variablea name used in a function but defined in an enclosing scope

Professional sentences

  • "The assignment makes this identifier local to the function."
  • "The callback closes over the loop variable rather than its current value."
  • "The default argument captures the value at function-definition time."
  • "This global dependency should be passed explicitly."
  • "The method must use attribute lookup through self."
  • "The comprehension has its own loop-variable scope."

23. Speaking task

Explain the following error:

count = 0


def increment():
print(count)
count += 1

Your explanation must use:

  • local binding;
  • assignment;
  • UnboundLocalError;
  • lexical scope;
  • global;
  • preferred refactoring.

24. Writing task

Write a 300-word pull-request review for this code:

handlers = []

for route in routes:
handlers.append(lambda request: dispatch(route, request))

Explain:

  • the late-binding bug;
  • how to reproduce it;
  • two fixes;
  • which fix you prefer;
  • what test should be added.

25. Exercises

Exercise 1: LEGB

Predict:

value = "global"


def outer():
value = "enclosing"

def inner():
print(value)

inner()


outer()

Exercise 2: Local classification

Explain why this fails:

status = "ready"


def run():
print(status)
status = "running"

Write three valid alternatives.

Exercise 3: Counter closure

Implement:

counter = create_counter(start=10, step=5)

counter() # 15
counter() # 20
counter() # 25

Exercise 4: Late binding

Fix:

multipliers = []

for factor in range(1, 4):
multipliers.append(lambda value: value * factor)

Expected:

[multiplier(10) for multiplier in multipliers]
# [10, 20, 30]

Exercise 5: Generator lookup

Predict:

factor = 2
values = (number * factor for number in range(3))
factor = 100
print(list(values))

Explain why.

Exercise 6: Module binding

Given:

# config.py
DEBUG = False

What is the difference between:

from config import DEBUG
DEBUG = True

and:

import config
config.DEBUG = True

Exercise 7: Closure-based retry policy

Implement a function:

retry_policy = create_retry_policy(max_attempts=3)

The returned function accepts a current attempt number and returns True when another attempt is allowed.


26. Complete solutions

Solution 1

Output:

enclosing

The local scope of inner has no value. Python finds value in the enclosing outer scope before checking the global scope.

Solution 2

It fails because assignment to status makes status local throughout run. The print tries to read the uninitialized local binding.

Alternative 1: return a value.

status = "ready"


def run(current_status: str) -> str:
print(current_status)
return "running"


status = run(status)

Alternative 2: use global.

status = "ready"


def run():
global status
print(status)
status = "running"

This works but introduces shared global mutation.

Alternative 3: encapsulate state.

from dataclasses import dataclass


@dataclass
class Process:
status: str = "ready"

def run(self) -> None:
print(self.status)
self.status = "running"

The first or third design is usually easier to test.

Solution 3

from collections.abc import Callable


def create_counter(
start: int = 0,
step: int = 1,
) -> Callable[[], int]:
value = start

def increment() -> int:
nonlocal value
value += step
return value

return increment

Solution 4

Default-argument capture:

multipliers = []

for factor in range(1, 4):
multipliers.append(
lambda value, factor=factor: value * factor
)

Factory solution:

def create_multiplier(factor: int):
def multiply(value: int) -> int:
return value * factor

return multiply


multipliers = [
create_multiplier(factor)
for factor in range(1, 4)
]

The factory is often clearer when the callback has more logic.

Solution 5

Output:

[0, 100, 200]

The generator expression is lazy. factor is looked up while iteration occurs, after it has been rebound to 100.

Solution 6

First form:

from config import DEBUG
DEBUG = True

The importing module receives its own name DEBUG, initially bound to the same boolean object. Reassigning the local name does not change config.DEBUG.

Second form:

import config
config.DEBUG = True

The module object is imported, and its DEBUG attribute is changed.

Solution 7

from collections.abc import Callable


def create_retry_policy(
max_attempts: int,
) -> Callable[[int], bool]:
if max_attempts < 1:
raise ValueError("max_attempts must be positive")

def should_retry(current_attempt: int) -> bool:
if current_attempt < 1:
raise ValueError("current_attempt must be positive")

return current_attempt < max_attempts

return should_retry


retry_policy = create_retry_policy(max_attempts=3)

assert retry_policy(1) is True
assert retry_policy(2) is True
assert retry_policy(3) is False

27. Chapter checkpoint

You are ready to continue when you can explain:

  1. The difference between a namespace and a scope.
  2. The LEGB lookup order.
  3. Why assignment can produce UnboundLocalError.
  4. The difference between global and nonlocal.
  5. What a closure retains.
  6. Why loop callbacks can suffer from late binding.
  7. Why default arguments capture definition-time values.
  8. Why class attributes require attribute lookup rather than bare lexical lookup.
  9. How module imports create bindings.