Scope, closures, and mutability
Where names live, why a function cannot reassign a global, and how passing a list differs from passing a number.
Why this matters in AI / ML / GenAI
Mutable default arguments and accidentally shared state cause bugs that only appear on the second call — exactly the kind that survive testing and break in production batch jobs.
The LEGB rule
Python resolves a name by searching four scopes in order:
- Local — inside the current function
- Enclosing — an outer function, for nested definitions
- Global — module level
- Built-in —
len,print,sum, and friends
Reading a global from inside a function is fine. Assigning to that name creates a new local instead, which produces UnboundLocalError if you read it before assigning in the same function.
global x and nonlocal x opt out of that behaviour. Both are usually a design smell — prefer passing values in and returning results out, which keeps functions testable.
Never shadow built-ins. Naming a variable list, dict, sum, id, or type breaks the built-in for the rest of that scope.
Mutable vs immutable arguments
Python passes references to objects. What changes is whether the object itself can be modified.
- Immutable (int, float, str, tuple, frozenset): a function cannot alter the caller's value. Rebinding inside the function is purely local.
- Mutable (list, dict, set, and most custom objects): a function can modify the caller's object in place, and that change is visible outside.
So items.append(x) inside a function affects the caller, while items = items + [x] does not — the second creates a new list bound to a local name.
Either mutate deliberately and document it, or copy first. list(original) and dict(original) give shallow copies; copy.deepcopy handles nested structures, at a cost.
Closures
A closure is a nested function that captures variables from its enclosing scope and keeps them alive after the outer function returns.
That is the machinery behind decorators, and behind factory functions like make_scorer(threshold) that produce a configured function.
The classic trap: closures capture the variable, not its value at creation time. Creating functions in a loop that all reference the loop variable gives every one of them the final value. Bind the value with a default argument (lambda x, t=threshold: ...) or use functools.partial.
Copy-paste examples
Copy into your own editor, or load one into the compiler below and press Run.
LEGB and UnboundLocalError
Reading a global works; assigning to it makes the name local.
model_name = "global-model"
def read_only():
return f"reading: {model_name}"
def shadowed():
model_name = "local-model" # new local, global untouched
return f"local: {model_name}"
def broken():
print(model_name) # error: local assigned below
model_name = "oops"
print(read_only())
print(shadowed())
print("global still:", model_name)
try:
broken()
except UnboundLocalError as err:
print("UnboundLocalError:", err)Mutable arguments change the caller's data
append mutates; rebinding does not. Run it and compare.
def mutates(items):
items.append("added inside")
return items
def rebinds(items):
items = items + ["added inside"]
return items
original_a = ["start"]
mutates(original_a)
print("after mutates :", original_a, "<- caller changed")
original_b = ["start"]
rebinds(original_b)
print("after rebinds :", original_b, "<- caller untouched")
def safe(items):
local = list(items) # copy first
local.append("added inside")
return local
original_c = ["start"]
print("safe returns :", safe(original_c), "| original:", original_c)Shallow vs deep copy
A shallow copy shares the nested objects.
import copy
config = {"model": "m1", "params": {"lr": 1e-3}}
shallow = dict(config)
shallow["params"]["lr"] = 999
print("after shallow edit, original lr:", config["params"]["lr"], "<- changed too")
config["params"]["lr"] = 1e-3
deep = copy.deepcopy(config)
deep["params"]["lr"] = 999
print("after deep edit, original lr :", config["params"]["lr"], "<- safe")Closures and the late-binding trap
The first list of functions all return the same value. The fix binds the value.
def make_threshold_filter(threshold):
def keep(score):
return score >= threshold
return keep
strict = make_threshold_filter(0.9)
loose = make_threshold_filter(0.5)
print("strict(0.7):", strict(0.7), "| loose(0.7):", loose(0.7))
broken = [lambda s: s >= t for t in (0.5, 0.7, 0.9)]
fixed = [lambda s, t=t: s >= t for t in (0.5, 0.7, 0.9)]
print("\nbroken (all use 0.9):", [f(0.8) for f in broken])
print("fixed (0.5/0.7/0.9):", [f(0.8) for f in fixed])Find the shared-state bug
Try it — in-browser Python
Both trackers share one list. Fix add_result by giving each tracker its own store.
Output
Python runs in your browser. First run downloads the runtime.
Press Run (or Ctrl+Enter) to execute.
CPython in WebAssembly. Stdlib works. NumPy, pandas, scikit-learn and Matplotlib load on demand, and charts render below. No input(), no GPU, no network installs.
Takeaways
- Names resolve Local, Enclosing, Global, Built-in; assigning makes a name local.
- Mutable arguments can be changed by the callee — copy first if that is not intended.
- Closures capture variables, not values; bind with a default argument inside loops.