Engineering Notes
Home/Languages/Python/Variables and Scope
Languages · Python

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.

💻 Languages● Beginner⏱ 6 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Names Are Bindings, Not Boxes
  • The LEGB Rule
  • Assignment Makes a Name Local — Even Before the Assignment Line Runs
  • global and nonlocal
  • 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.

python
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 list

2The LEGB Rule

Name resolution looks through four scopes, in this order: Local, Enclosing, Global, Built-in.

python
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
ScopeWhereExample
Localcurrent function bodya variable assigned inside inner()
Enclosingany enclosing function (not module level)x in outer() as seen from inner()
Globalmodule top levelx defined outside any function
Built-inthe builtins modulelen, 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.

python
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

python
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 increment

global 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

python
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 untouched

append_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

python
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:

python
def f():
    x = 1
    return x

compiles 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:

python
y = 1
def g():
    return y

where 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.

python
def f(a, b):
    c = a + b
    return c

print(f.__code__.co_varnames)  # ('a', 'b', 'c') — fixed slot order

Closures 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.

python
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',)
count lives in a heap cell object; make_counter's frame and inner's function object both reference it through LOAD_DEREF / STORE_DEREF
count lives in a heap cell object; make_counter's frame and inner's function object both reference it through LOAD_DEREF / STORE_DEREF

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

  1. UnboundLocalError from 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. Add global/nonlocal if you meant to reuse an outer name.
  2. 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.
  3. Assuming for loop variables are scoped to the loop — they are not; for i in range(3): pass leaves i bound at 2 in the enclosing scope after the loop. Comprehensions, unlike loops, don't have this leak.
  4. Forgetting nonlocal when mutating an enclosing count/flag — without it, count += 1 inside a nested function raises UnboundLocalError, because += is a read-then-assign, and the assignment half makes count local.

📋 Quick Reference

python
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.