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 valuePython 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 assignmentBoth 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 nameat 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,forortry, assign it before the branch or add the missingelse. - For
try/finallycleanup of a resource that may not have been created, use awithstatement. - 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 globallycounter += 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 = 0Other shapes of the same rule:
- Conditional assignment:
if raw.isdigit(): value = int(raw)followed byreturn value. Works for"42", fails for"abc". - try/finally:
f = open(path)insidetry,f.close()infinally. Ifopenraises,fwas never bound, so the cleanup fails withcannot access local variable 'f', hiding the original error. - except … as err: Python deletes
errat the end of theexceptblock, so using it afterwards raises the same error. - Closures: an inner function that does
count += 1on an outer function’s variable makescountlocal 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)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}")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) 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 valueDiagnosis. 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 1counts 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 1The 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")$ python3 test_levels.py
okA 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 = 0Answer 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.