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.
1. Read
Understand the idea in plain English first.
2. Run
Load any example into the compiler and press Run.
3. Change
Edit one value, rerun, and learn from the output.
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.
Hands-on practice
Compiler on the left. Examples on the right.
On desktop, keep the compiler beside the examples. On mobile, the same blocks stack cleanly. Pick an example, try it in the compiler, then change one small thing.
Find the shared-state bug
Live Python compiler
Try it — in-browser Python
Both trackers share one list. Fix add_result by giving each tracker its own store.
Code editor
Output
Python runs in your browser. First run downloads the runtime.
Press Run (or Ctrl+Enter) to execute.
Runs CPython in your browser. NumPy, pandas, scikit-learn and Matplotlib load on demand. Charts appear below the output. No input(), no GPU, no network installs.
Clear code examples
Every example is copy-ready. Use Try in compiler when you want to experiment without scrolling around.
Example
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)Example
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)Example
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")Example
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])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.