Intermediate18 min

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:

  1. Local — inside the current function
  2. Enclosing — an outer function, for nested definitions
  3. Global — module level
  4. Built-inlen, 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.