Scope and the LEGB Rule
Understand Python variable scope: Local, Enclosing, Global, and Built-in lookup order. Covers closures, nonlocal/global keywords, common bugs, and patterns used in LangChain callbacks and AI pipelines.
What is Scope?
Scope determines where a variable name is visible. Python uses the LEGB rule to look up names: Local ā Enclosing ā Global ā Built-in.
x = "global" # Global scope
def outer():
x = "enclosing" # Enclosing scope
def inner():
x = "local" # Local scope
print(x) # "local" ā LEGB stops at the first match
inner()
print(x) # "enclosing"
outer()
print(x) # "global"L ā Local Scope
Variables defined inside a function are local to that function.
def compute_loss(y_true: float, y_pred: float) -> float:
error = y_true - y_pred # Local ā not visible outside
return error ** 2
compute_loss(1.0, 0.8)
# print(error) # NameError: 'error' is not defined ā it's localE ā Enclosing Scope
A nested function can read variables from its enclosing function's scope (this is a closure).
def make_threshold_filter(threshold: float):
"""Returns a filter function that remembers the threshold."""
def filter_scores(scores: list[float]) -> list[float]:
# 'threshold' is in the enclosing scope ā not passed as argument
return [s for s in scores if s >= threshold]
return filter_scores
high_confidence = make_threshold_filter(0.85)
print(high_confidence([0.9, 0.4, 0.87, 0.6])) # [0.9, 0.87]
low_confidence = make_threshold_filter(0.5)
print(low_confidence([0.9, 0.4, 0.87, 0.6])) # [0.9, 0.87, 0.6]G ā Global Scope
Variables defined at the module level are global. Functions can read globals without any keyword.
MODEL_NAME = "gpt-4o" # Global constant ā readable anywhere
call_count = 0 # Global variable ā needs 'global' to modify
def call_llm(prompt: str) -> str:
global call_count # Required to MODIFY a global
call_count += 1
return f"[{MODEL_NAME}] Response {call_count}"
call_llm("hello")
call_llm("world")
print(call_count) # 2Prefer constants over mutable globals. Mutable globals are hard to test and can cause subtle bugs. If you need shared mutable state, use a class instead.
B ā Built-in Scope
Python's built-in names (len, print, range, type, int, list, etc.) are always available and are the last fallback in LEGB.
print(len([1, 2, 3])) # 3 ā 'len' from built-in scope
# Shadowing a built-in (don't do this)
len = 42 # Overwrites the built-in 'len' in this scope
# len([1, 2, 3]) # TypeError: 'int' object is not callable
# Fix: delete the shadowing name to restore access
del len
print(len([1, 2, 3])) # 3 ā built-in restoredAvoid naming variables list, dict, set, type, id, input, min, max, sum, filter, map. These shadow built-ins and cause confusing bugs.
The global Keyword
total_tokens_used = 0
def process_request(prompt: str) -> str:
global total_tokens_used
tokens = len(prompt.split()) * 2 # Rough estimate
total_tokens_used += tokens
return f"Processed: {prompt[:20]}..."
process_request("What is warfarin?")
process_request("Explain anticoagulation therapy.")
print(total_tokens_used) # some number ā shared across callsFor production code, prefer a class that encapsulates state:
class TokenTracker:
def __init__(self):
self.total = 0
def add(self, n: int) -> None:
self.total += n
def report(self) -> str:
return f"Total tokens used: {self.total}"
tracker = TokenTracker()The nonlocal Keyword
nonlocal lets an inner function modify a variable in its enclosing (not global) scope.
def make_counter(start: int = 0):
count = start # Enclosing variable
def increment(by: int = 1) -> int:
nonlocal count # Required to modify 'count' from enclosing scope
count += by
return count
def reset() -> None:
nonlocal count
count = start
return increment, reset
inc, rst = make_counter(0)
print(inc()) # 1
print(inc()) # 2
print(inc(5)) # 7
rst()
print(inc()) # 1 ā reset workedClosures in AI Pipelines
Closures are commonly used to create configurable functions:
from langchain_core.runnables import RunnableLambda
def make_token_truncator(max_tokens: int):
"""Returns a function that truncates text to a token budget."""
words_per_token = 0.75 # Captured in closure
def truncate(text: str) -> str:
max_words = int(max_tokens * words_per_token)
words = text.split()
if len(words) <= max_words:
return text
return " ".join(words[:max_words]) + "..."
return truncate
truncate_4k = make_token_truncator(4096)
truncate_1k = make_token_truncator(1024)
# Wrap in LCEL
truncate_step = RunnableLambda(truncate_4k)The Loop Variable Closure Bug
A common gotcha: closures capture the variable, not its value at definition time.
# Bug: all functions reference the same 'i' variable
funcs = [lambda: i for i in range(3)]
print(funcs[0]()) # 2 ā NOT 0!
print(funcs[1]()) # 2 ā NOT 1!
print(funcs[2]()) # 2 ā all see the final value of i
# Fix 1: use a default argument to capture the current value
funcs = [lambda i=i: i for i in range(3)]
print(funcs[0]()) # 0
print(funcs[1]()) # 1
print(funcs[2]()) # 2
# Fix 2: use functools.partial
from functools import partial
def make_retriever(index: int) -> int:
return index
funcs = [partial(make_retriever, i) for i in range(3)]
print(funcs[0]()) # 0Common Scope Bugs
# Bug 1: assigning to a name makes it local ā even reads before the assignment fail
x = 10
def buggy():
print(x) # UnboundLocalError: x referenced before assignment
x = 20 # This assignment makes x local in the entire function
# Fix: either use 'global x' or don't assign to it locally
# Bug 2: mutable global modified without 'global' keyword still works
results = [] # Mutable global
def add_result(val):
results.append(val) # Mutates the global list ā no 'global' needed
# results = [val] # This would create a local, NOT modify the global
add_result("drug_a")
print(results) # ["drug_a"]
# Bug 3: shadowing a variable in a comprehension (Python 3 is safe here)
x = "outer"
squares = [x for x in range(5)] # x inside comprehension is its own scope in Python 3
print(x) # "outer" ā comprehension doesn't leak in Python 3Scope in Class Bodies
class DrugClassifier:
CLASSES = ["anticoagulant", "antidiabetic", "antihypertensive"] # Class scope
count = 0 # Class scope
def classify(self, drug: str) -> str:
# 'CLASSES' is NOT directly visible here ā must use self.CLASSES or DrugClassifier.CLASSES
for cls in self.CLASSES: # 'self' is local, refers to instance
if cls in drug.lower():
return cls
return "unknown"
@classmethod
def increment(cls) -> None:
cls.count += 1 # 'cls' is local (the class itself)LEGB Quick Reference
| Scope | Where Defined | Keyword to Modify | Example |
|---|---|---|---|
| Local | Inside current function | (none needed) | x = 1 inside def f() |
| Enclosing | Outer function (closures) | nonlocal | Inner function reads outer var |
| Global | Module top-level | global | Constants, module state |
| Built-in | Python interpreter | (can shadow, don't) | len, print, range |
Lookup order: Python searches L ā E ā G ā B and stops at the first match. If a name is not found in any scope, you get a NameError.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.