Ch. 9 · Python

UnboundLocalError: cannot access local variable in Python

Fix UnboundLocalError: cannot access local variable (once 'referenced before assignment'): why assignment makes a name local, global, nonlocal.

~7 min readintermediateupdated Oct 4, 2026

A function reads a variable that clearly exists, and Python 3.11 or newer says (Python 3.14.7 output, paths shortened):

Traceback (most recent call last):
  File "ub1.py", line 7, in <module>
    increment()
    ~~~~~~~~~^^
  File "ub1.py", line 4, in increment
    counter += 1
    ^^^^^^^
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value
Text

Python 3.10 and older word the same error differently, so you will see both in search results and old answers:

UnboundLocalError: local variable 'counter' referenced before assignment
Text

Both mean the same thing: inside this function, counter is a local variable (because the function assigns to it), and at the moment it was read, that local had not been given a value. The module-level counter = 0 is never consulted.

Quick fix checklist

  • Search the function for every binding of the name: name =, name +=, for name in, with ... as name, except ... as name, import name, del name. Any one of them makes it local everywhere in the function.
  • If you meant the module-level variable, pass it in as a parameter and return the new value. global name at the top of the function also works, but is a last resort.
  • If you meant a variable of an enclosing function (a closure), declare nonlocal name.
  • If the variable is genuinely local but only assigned inside an if, for or try, assign it before the branch or add the missing else.
  • For try/finally cleanup of a resource that may not have been created, use a with statement.
  • Run pyright or Ruff: both report this before the code runs.

Before you start

You should know what a function, a module-level variable and a nested function are. The examples ran on Python 3.14.7, and the pre-3.11 wording was checked on Python 3.9.6. UnboundLocalError is a subclass of NameError, so except NameError also catches it.

Why it happens

Python decides the scope of every name when it compiles the function, not line by line as it runs. The compiler scans the whole body; if the name is bound anywhere in it, the name is local for the entire function, including the lines before the binding. Lookups for local names never fall back to the global scope.

You can see the decision in the compiled code object:

counter = 0

def increment():
    counter += 1

def show():
    print(counter)

print(increment.__code__.co_varnames)  # ('counter',)  local
print(show.__code__.co_varnames)       # ()            not local, looked up globally
python

counter += 1 is a read followed by an assignment, so it needs a value that the local does not have yet. The same rule produces this surprising case, where the failing line comes before the assignment:

total = 100

def report():
    print("total is", total)   # UnboundLocalError on this line
    total = 0
python

Other shapes of the same rule:

  • Conditional assignment: if raw.isdigit(): value = int(raw) followed by return value. Works for "42", fails for "abc".
  • try/finally: f = open(path) inside try, f.close() in finally. If open raises, f was never bound, so the cleanup fails with cannot access local variable 'f', hiding the original error.
  • except … as err: Python deletes err at the end of the except block, so using it afterwards raises the same error.
  • Closures: an inner function that does count += 1 on an outer function’s variable makes count local to the inner function.

What does not trigger it: mutating an object through a global name. hits["n"] += 1 or items.append(x) reads the global hits or items and changes its contents; the name itself is never reassigned.

Step-by-step walkthrough

Step 1: Read which name and which line

The message names the variable, and the caret marks the read that failed. Note the function name in the last traceback frame: the binding that made the name local is somewhere in that function, not necessarily near the caret.

Step 2: Find the binding that made it local

Search that function for the name on the left side of =, in += and friends, after for, after as and in import or del. If you are not sure, ask Python: func.__code__.co_varnames lists the locals. Static tools also find it:

$ ruff check levels.py      # first finding of several
F823 Local variable `errors` referenced before assignment
 --> levels.py:7:9

$ pyright parse.py
  parse.py:4:12 - error: "value" is possibly unbound (reportPossiblyUnboundVariable)
Text

Ruff (0.16) catches read-before-assign; pyright (1.1.414) also catches the conditional case. mypy needs --enable-error-code possibly-undefined and annotated functions to report Name "value" may be undefined.

Step 3: Decide which variable you meant

This is a design decision, not a syntax fix:

# You meant the enclosing function's variable: nonlocal
def make_counter():
    count = 0
    def step():
        nonlocal count
        count += 1
        return count
    return step

# You meant the module variable: global (works, but see below)
counter = 0
def increment():
    global counter
    counter += 1
    return counter

# You meant a real local: give it a value on every path
def parse(raw: str) -> int:
    if raw.isdigit():
        return int(raw)
    raise ValueError(f"not a number: {raw!r}")
python

nonlocal only searches enclosing functions. Using it for a module variable is a compile-time error: SyntaxError: no binding for nonlocal 'counter' found.

Step 4: Prefer data flow over shared names

global makes a function depend on hidden state: it cannot be tested in isolation, two callers share one counter, and in threaded code counter += 1 is a read-modify-write that is not atomic across threads. Passing values in and returning results removes the error and those problems at once. The worked scenario shows the refactor.

Worked scenario

A script tallies log levels with two module-level counters:

errors = 0
warnings = 0


def process(line):
    if "ERROR" in line:
        errors += 1
    elif "WARNING" in line:
        warnings += 1


for line in ["INFO boot", "ERROR disk full", "WARNING slow"]:
    process(line)
print(errors, warnings)
python
  File "levels.py", line 13, in <module>
    process(line)
    ~~~~~~~^^^^^^
  File "levels.py", line 7, in process
    errors += 1
    ^^^^^^
UnboundLocalError: cannot access local variable 'errors' where it is not associated with a value
Text

Diagnosis. errors += 1 binds errors inside process, so it is a local for the whole function, and its first use reads an empty local. The INFO line passed because neither branch ran. Ruff reports F823 on lines 7 and 9 without running anything.

Fix. Adding global errors, warnings would make it run, but the function would still depend on hidden module state, and running it twice in one process would keep adding to the old totals. Return the result instead:

from collections import Counter


def count_levels(lines):
    counts = Counter()
    for line in lines:
        level = line.split(maxsplit=1)[0]
        if level in ("ERROR", "WARNING"):
            counts[level] += 1
    return counts


counts = count_levels(["INFO boot", "ERROR disk full", "WARNING slow", "ERROR retry"])
print(counts["ERROR"], counts["WARNING"])   # 2 1
python

counts is created inside the function, so it is local by design. counts[level] += 1 mutates the Counter; it does not rebind counts. Matching the first word also stops a message like INFO retrying after ERROR from being counted as an error.

Common mistake

The quickest-looking fix is to “define” the variable inside the function:

counter = 0

def increment():
    counter = 0      # silences the error
    counter += 1
    return counter

print(increment(), increment())   # 1 1
python

The error disappears, but now there are two unrelated variables named counter, and the function always returns 1. The bug has moved from loud to silent.

The other overreaction is sprinkling global over every function that touches shared state. Each global is an invisible input and output; a few of them make code order-dependent and hard to test. Reach for global only for genuine module-level configuration that is set once, and for closures use nonlocal or a small class. If the closure behaviour itself is confusing, closure binding in Python explains when inner functions see outer variables.

Verify the behavior

Test the cases that used to fail, and test that state does not leak between calls:

from levels_fixed import count_levels

assert count_levels([]) == {}
first = count_levels(["ERROR a", "WARNING b"])
second = count_levels(["ERROR c"])
assert first["ERROR"] == 1 and first["WARNING"] == 1
assert second["ERROR"] == 1   # no state leaks between calls
print("ok")
python
$ python3 test_levels.py
ok
Text

A version built on module-level counters would fail the third assertion, because the second call would start from the first call’s totals. Add ruff check or pyright to CI so the next read-before-assign is caught before it ships.

Interview exercise

“Why does this raise UnboundLocalError on the print line, when total is defined globally and the assignment comes after the print?”

total = 100

def report():
    print("total is", total)
    total = 0
python

Answer and reasoning

Scope is decided at compile time for the whole function body. Because report contains total = 0, the compiler marks total as local to report, and every reference to total in the function, including the print before the assignment, reads the local slot. When print runs, that slot is empty, so Python raises UnboundLocalError (cannot access local variable 'total' where it is not associated with a value on 3.11+, local variable 'total' referenced before assignment before that). It never falls back to the global, because a name cannot be local in one line and global in the next within the same function.

The fix depends on intent: rename the local if it is unrelated; declare global total if the function really should reset the module variable; or, better, pass total in and return the new value. A strong answer also mentions that the same rule explains nonlocal in closures and why cache[key] = value on a global dict works without global: mutating an object is not binding a name.

Continue learning

Practise scope questions with the Python interview questions and the Python MCQs. Related notes: closure binding, comprehension scope and mutable default arguments. If the error is about None rather than a missing value, see TypeError: ‘NoneType’ object is not subscriptable. Official references: the Python FAQ entry why am I getting an UnboundLocalError, resolution of names, and the global and nonlocal statements.

More in Python

esc