skip to content

Why does UnboundLocalError appear when a function does `x = x + 1` and x is a module-level global?

level: juniorimportance: must knowfreq 78%

answer

  1. Scope is fixed when the def compiles
  2. The whole body, not just after the line
  3. One assignment anywhere marks a name local
  4. The read hits an empty local slot
  5. global or nonlocal, or stop rebinding

basics

~20 s

Python decides scope at compile time: an assignment to x anywhere in the body makes x local for the whole function, so the read on the right-hand side hits an unset local slot instead of the module-level global.

solid answer

~40 s

Scope in Python is decided at **compile time**, per code block, from assignments — not at run time from what happens to exist. When CPython compiles the `def`, it builds a symbol table for the body; because `x` is the target of an assignment somewhere in that body, `x` becomes a local name for the *whole* body, including lines above the assignment. The local slot starts empty, so evaluating the right-hand side reads an unbound local and raises `UnboundLocalError` — on 3.14 the message is "cannot access local variable 'x' where it is not associated with a value". The fixes are to declare `global x` (or `nonlocal x` when the binding lives in an enclosing function), or better, to stop rebinding: take the value as a parameter and return the new one.

code

python · 12 lines
python
counter = 0

def bump():
    counter = counter + 1
    return counter

try:
    bump()
except UnboundLocalError as exc:
    print(exc)
    print(isinstance(exc, NameError))
    print(bump.__code__.co_varnames)

go deeper

for a junior

Recall the one-liner: an assignment anywhere in a function body makes that name local for the entire body, so reading it first raises UnboundLocalError. Be able to point at the offending line and add global or restructure the function to return a value.

for a middle

Explain the mechanics: the compiler builds a symbol table for the block before emitting bytecode, locals live in frame slots rather than a dict, and a never-stored slot triggers the checked load. List the binding forms beyond = that also mark a name local.

for a senior

Show judgment about the fix, not just the diagnosis. global makes the error go away and usually makes the design worse; prefer passing state in and returning it. Be ready to explain why the conditional-assignment variant only shows up on rare inputs in production.

for a principal

Own the pattern at codebase scale: module-level mutable state rebound from functions creates order-dependent behaviour that this error merely exposes. Argue for explicit state ownership and for lint rules that flag possibly-unbound names before they reach a rare production path.

## The rule **Python decides which names are local at compile time, per code block, from assignments — never at run time from what happens to be defined.** If a name is the target of a binding operation anywhere in a function body, that name is local for the *entire* body, including every line that textually precedes the assignment. When CPython compiles a `def`, it makes two passes over the body. The first builds a symbol table: it walks the statements and records, for each name, whether the block binds it. "Binds" is broader than the `=` operator — all of these mark a name local: - `x = ...` and augmented forms such as `x += 1` - `for x in ...` - `with open(p) as x:` - `except ValueError as x:` - `import x` and `from m import x` - `def x(): ...` and `class x: ...` - the walrus target in `(x := ...)` - `del x` — which marks the name local *and* leaves it unbound The second pass emits bytecode. Local names live in a fixed array of slots on the frame rather than in a dictionary, so reads compile to a fast indexed instruction rather than a dictionary lookup. A slot that has never been stored to holds a sentinel meaning "empty", and when the compiler can see a read that may hit an empty slot it emits the checked variant of the load. That check is what raises `UnboundLocalError`. ## Why the classic example bites ```python counter = 0 def bump(): counter = counter + 1 # both a read and a write of `counter` return counter ``` The single statement is both a write (which makes `counter` local) and a read (which happens first, at run time). The compiler already committed: inside `bump`, `counter` means the local slot. The module-level `counter` is not consulted at all. `bump.__code__.co_varnames` is `('counter',)`, and disassembling the function shows the checked local load rather than a global load — that is the mechanical proof. The surprise is sharpest with `+=`, because it reads like a mutation of something that already exists. It is not: augmented assignment on an immutable `int` is a read, an add, and a rebind. ## What the error is `UnboundLocalError` is a subclass of `NameError`. The distinction is diagnostic: `NameError` means "no such name anywhere" — usually a typo or a missing import; `UnboundLocalError` means "this name is definitely local here and has no value yet" — usually a scoping mistake or a code path that skipped the assignment. Python 3.11 rewrote the message from the older "local variable 'x' referenced before assignment" to "cannot access local variable 'x' where it is not associated with a value", so tracebacks from 3.11 onward read differently from older ones. ## The fixes, in the order to reach for them **1. Don't rebind a global at all.** The best fix for `bump` is usually to pass the value in and return the new one, leaving the caller to own the state. Reassigning module-level names from inside functions makes call order load-bearing and is what created the confusion. **2. Declare `global x`.** If the function really must rebind a module-level name, `global x` at the top of the body tells the compiler not to make `x` local; reads and writes then both target the module namespace. **3. Declare `nonlocal x`.** If the binding you want to rebind lives in an enclosing *function* (not the module), `nonlocal x` targets that binding instead. It requires the enclosing binding to already exist. **4. Mutate instead of rebind.** `items.append(row)` never rebinds `items`, so it never makes `items` local. Only rebinding does. This is why a function can freely mutate a global list or dict without any declaration, yet cannot do `count += 1` on a global int. ## The mental model to carry into an interview The name of the leaf is *local detection*: the interpreter is not searching outward for `x` at the moment the line runs and failing — it never searches at all. The lookup strategy for every name in the body was fixed when the function was compiled, and one assignment anywhere in that body is enough to fix it as "local". Once that clicks, the conditional variant (a name assigned only inside an `if` or a `try` that a rare input skips) stops being a separate mystery and becomes the same rule seen from another angle.

  • Does moving the assignment to the last line of the function change anything?
    No. The symbol table is built from the whole body before any bytecode runs, so an assignment on the final line still makes the name local on the first line. Position within the body is irrelevant; only presence matters. This is the single most common wrong intuition — that the compiler decides line by line as execution reaches each statement.
  • Why can a function append to a global list without declaring anything, but not do `count += 1` on a global int?
    `rows.append(row)` reads the name `rows` and calls a method on the object; it never rebinds the name, so the name stays global. `count += 1` rebinds the name `count`, which marks it local for the whole body, so the read that `+=` performs first hits an empty local slot. The rule is about rebinding names, not about mutating objects.
  • Which other statements besides `=` mark a name local?
    Any binding operation in the body: augmented assignment, a `for` target, `with ... as`, `except ... as`, `import`, `from ... import`, a nested `def` or `class` of that name, a walrus target, and `del`. `del x` is the sharp one — it makes `x` local and simultaneously leaves it unbound, so a later read raises the same error.

The compiler stamps each name in the body with a passport before the function ever runs. Assigning to x once stamps it 'local' for every line, so the earlier read shows a local passport at a border where no local value has arrived yet.

saying these in an interview costs you the question

  • Claims Python resolves scope at run time by searching outward
  • Says the error means the global was never defined
  • Thinks only assignments above the read count
  • Suggests wrapping the call in try/except to hide it
  • Believes `global` is needed just to read a module-level name
  • Confuses mutating a global list with rebinding a global name

context