Variables and Scope
A Python "variable" is not a typed memory slot — it is a name bound to an object in some namespace. Assignment binds (or rebinds) a name; it never copies the object.
🎯 What you'll learn
- Names Are Bindings, Not Boxes
- The LEGB Rule
- Assignment Makes a Name Local — Even Before the Assignment Line Runs
globalandnonlocal- Mutating vs Rebinding — a Frequent Source of Confusion
1Names Are Bindings, Not Boxes
A Python "variable" is not a typed memory slot — it is a name bound to an object in some namespace. Assignment binds (or rebinds) a name; it never copies the object.
x = [1, 2, 3]
y = x # y is bound to the SAME list object as x
y.append(4)
print(x) # [1, 2, 3, 4] — x sees the mutation because there's only ONE list2The LEGB Rule
Name resolution looks through four scopes, in this order: Local, Enclosing, Global, Built-in.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local" — found in Local scope first
inner()
print(x) # "enclosing" — outer's own local
print(x) # "global"
print(len) # found in Builtin scope — never shadowed here| Scope | Where | Example |
|---|---|---|
| Local | current function body | a variable assigned inside inner() |
| Enclosing | any enclosing function (not module level) | x in outer() as seen from inner() |
| Global | module top level | x defined outside any function |
| Built-in | the builtins module | len, print, Exception |
3Assignment Makes a Name Local — Even Before the Assignment Line Runs
Python decides whether a name is local to a function at compile time, by scanning the whole function body for assignment targets — not by tracing execution order.
x = "global"
def broken():
print(x) # UnboundLocalError, NOT "global"!
x = "local"
broken()Because x = "local" appears anywhere in broken's body, the compiler marks x as local to the entire function. The print(x) on the line before the assignment tries to read that local before it has a value, raising UnboundLocalError — Python never silently "falls back" to the global x mid-function.
4global and nonlocal
counter = 0
def increment():
global counter
counter += 1 # without `global`, this would be UnboundLocalError
def make_counter():
count = 0
def increment():
nonlocal count # binds to make_counter's `count`, not module global
count += 1
return count
return incrementglobal says "this name refers to the module's global scope, don't make it local." nonlocal says "this name refers to the nearest enclosing function scope, not local and not global." Both exist because reading an outer-scope variable works automatically (LEGB lookup), but assigning to a name always makes it local unless you explicitly opt out.
5Mutating vs Rebinding — a Frequent Source of Confusion
def append_item(lst, item):
lst.append(item) # mutation — no `global`/`nonlocal` needed, no new binding
def replace_list(lst):
lst = [] # rebinding — only changes the LOCAL name `lst`,
# the caller's list is untouchedappend_item works on the caller's actual list because .append() mutates the object both names point to. replace_list does not affect the caller — it just points the local name lst at a brand-new empty list, leaving the original object (and the caller's reference to it) unchanged.
6Comprehensions Have Their Own Scope
x = "outer"
squares = [x for x in range(5)]
print(x) # "outer" — the comprehension's `x` did NOT leak out (Python 3)
print(squares) # [0, 1, 4, 9, 16]Unlike a plain for loop (whose loop variable does leak into the enclosing scope), list/set/dict comprehensions and generator expressions run in their own implicit function scope in Python 3.
Under the HoodHow Scope Becomes Bytecode▾
The compiler decides the opcode per name, per scope, ahead of time
CPython's compiler performs static analysis of every function body before generating bytecode, classifying each name as local, cell (captured by/from a nested scope), global, or unbound. This produces different opcodes for what looks like "the same" variable access:
def f():
x = 1
return xcompiles x accesses to LOAD_FAST / STORE_FAST — direct, fixed-index slots in a fast local array on the frame, no dictionary lookup at all. Compare to:
y = 1
def g():
return ywhere y inside g compiles to LOAD_GLOBAL, which looks y up in the module's __dict__ (and falls back to builtins.__dict__ if not found there).
Local variables live in a fixed-size array, not a dict
A function's local variables are not stored in a dictionary at runtime. The compiler assigns each local name a fixed integer slot (visible as co_varnames on the function's code object), and the frame holds a plain array of PyObject* pointers indexed by slot. This is precisely why local variable access is one of the fastest operations in CPython — LOAD_FAST is an array index, not a hash lookup — while LOAD_GLOBAL and LOAD_NAME (used at module/class scope) genuinely hash into a dict.
def f(a, b):
c = a + b
return c
print(f.__code__.co_varnames) # ('a', 'b', 'c') — fixed slot orderClosures use cell objects, not a shared frame
When a name is assigned in an enclosing function and referenced by a nested function, the compiler can't use a plain fast-local slot for it — the nested function needs to keep reading and writing it after the enclosing call returns. Instead, that variable is stored in a heap-allocated cell object, and both functions access it through LOAD_DEREF / STORE_DEREF instead of LOAD_FAST.
def make_counter():
count = 0 # captured by inner() -> becomes a cell, not a fast local
def inner():
nonlocal count
count += 1
return count
return inner
print(make_counter.__code__.co_cellvars) # ('count',)Global and module scope are just a dict
A module's namespace, accessible via globals(), genuinely is a plain dict — assigning a module-level variable is a real dictionary insertion, and LOAD_GLOBAL is a real hash lookup (with a small-cache optimization in modern CPython to speed up repeated lookups of the same name). This is why globals()['x'] = 5 and x = 5 at module level are equivalent — one is just the explicit dict-mutation view of the other.
⚠️ Common Pitfalls
UnboundLocalErrorfrom assigning anywhere in a function — if a name is assigned anywhere in a function body, it's local for the whole function, even on lines that execute before the assignment. Addglobal/nonlocalif you meant to reuse an outer name.- Mutable default arguments interacting with scope — a
def f(items=[])default is created once at function-definition time and reused across calls; see the Functions topic for the full trap. - Assuming
forloop variables are scoped to the loop — they are not;for i in range(3): passleavesibound at2in the enclosing scope after the loop. Comprehensions, unlike loops, don't have this leak. - Forgetting
nonlocalwhen mutating an enclosing count/flag — without it,count += 1inside a nested function raisesUnboundLocalError, because+=is a read-then-assign, and the assignment half makescountlocal.
📋 Quick Reference
global x # inside a function: bind assignments to module scope
nonlocal x # inside a nested function: bind assignments to enclosing function scope
globals() # dict of the module's global namespace
locals() # snapshot dict of current local namespace (don't mutate to affect state)
f.__code__.co_varnames # fast-local names, in slot order
f.__code__.co_cellvars # names captured as cells (closed over by nested functions)🎤 Interview Q&A
Q1Explain Python's LEGB scope rule.core▾
Names are resolved in order: Local, Enclosing function scopes, Global (module), Built-in. Blocks like if/for don't create scopes.
- Does a for loop variable leak? Yes, it remains after the loop.
Q2What do global and nonlocal do?core▾
global x makes assignments in a function refer to the module-level name; nonlocal x refers to the nearest enclosing function's variable, used in closures with mutable counters.
- Why are they rarely needed? Prefer returning values or using objects.
Q3What's the UnboundLocalError gotcha?core▾
Assigning to a name anywhere in a function makes it local for the whole function, so reading it before the assignment raises UnboundLocalError even if a global exists.
- Fix? Pass it as a parameter or use global/nonlocal deliberately.
Q4How do closures capture variables in loops?senior▾
Late binding: lambdas created in a loop all see the final loop value. Bind early with default args (lambda i=i: i) or functools.partial.
- Example output?
[f() for f in [lambda: i for i in range(3)]]gives [2, 2, 2].
Q5How are module-level variables shared?core▾
Modules are singletons cached in sys.modules; module-level state is shared by all importers in a process, so treat it carefully (configuration, caches) especially with threads.
- Test isolation issue? State leaking between tests.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.