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:
- Local
- Enclosing
- Global
- 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
globalandnonlocalwork; - 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:
- read local
count; - add one;
- 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:
- Identify where the name is used.
- Search for assignments in the same function.
- Determine whether it is local, enclosing, global, or built-in.
- Check whether a closure looks up the value later.
- Check whether a default expression captured an earlier value.
- Rename shadowing variables.
- Replace hidden shared state with explicit dependencies.
22. English vocabulary
| Term | Meaning |
|---|---|
| namespace | a mapping from names to objects |
| scope | a region where names are directly accessible |
| enclosing scope | the scope of an outer function |
| shadowing | hiding another name with the same identifier |
| closure | a function retaining access to enclosing bindings |
| late binding | resolving a value when a function executes |
| capture | retaining access to a value or binding |
| rebinding | assigning a name to a different object |
| lexical | determined by the written structure of code |
| free variable | a 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:
- The difference between a namespace and a scope.
- The LEGB lookup order.
- Why assignment can produce
UnboundLocalError. - The difference between
globalandnonlocal. - What a closure retains.
- Why loop callbacks can suffer from late binding.
- Why default arguments capture definition-time values.
- Why class attributes require attribute lookup rather than bare lexical lookup.
- How module imports create bindings.