← All posts

Python UnboundLocalError: blame a different line

The traceback points at a print call. The cause is an assignment two lines further down, inside an if branch that never ran. Here's how Python knew about it.

Artur Kot 9 min read

This function totals a price, with or without a discount. Called without one, it shouldn’t change anything. It crashes with a Python UnboundLocalError instead:

price = 100

def total(discount):
    print("Price before discount:", price)
    if discount:
        price = price * 0.9
    return price

print(total(False))

Here’s the traceback from Python 3.14.7, with the file path shortened and nothing else touched:

Traceback (most recent call last):
  File "...\checkout.py", line 9, in <module>
    print(total(False))
          ~~~~~^^^^^^^
  File "...\checkout.py", line 4, in total
    print("Price before discount:", price)
                                    ^^^^^
UnboundLocalError: cannot access local variable 'price' where it is not associated with a value

The carets sit under price in the print call on line 4. That line only reads a variable that exists: price = 100 is right there at the top of the file. If line 4 were the whole function, it’d print 100 and nobody would think twice.

The cause of this Python UnboundLocalError is two lines down.

The assignment price = price * 0.9 on line 6 never ran, because discount is False. It didn’t have to run. It only had to be in the function. The traceback points at the read and never mentions line 6.

Python settles scope before the function runs

When the interpreter compiles a def, it reads the whole body, not just the lines a particular call will execute. Any name the function assigns to, anywhere, becomes a local variable. For the entire function, from the first line.

There’s no exception for branches that won’t run, and none for assignments that come after the read. Whether price on line 4 means the module’s variable isn’t decided by execution order. It’s decided by the fact that price = appears somewhere in the body.

The quickest check is locals() as the first statement of the same function. Right before the exception, it prints:

{'discount': False}

Only the parameter. price isn’t there, because nothing has stored a value in it yet, but Python still won’t go looking in the module.

The bytecode shows it more precisely. Here are two functions that differ only in whether they assign to count:

import dis

count = 0

def read():
    return count + 1

def bump():
    count += 1

dis.dis(read)
dis.dis(bump)
print(read.__code__.co_varnames, bump.__code__.co_varnames)

And what dis prints for them:

  5           RESUME                   0

  6           LOAD_GLOBAL              0 (count)
              LOAD_SMALL_INT           1
              BINARY_OP                0 (+)
              RETURN_VALUE
  8           RESUME                   0

  9           LOAD_FAST_CHECK          0 (count)
              LOAD_SMALL_INT           1
              BINARY_OP               13 (+=)
              STORE_FAST               0 (count)
              LOAD_CONST               1 (None)
              RETURN_VALUE
() ('count',)

In read() the lookup is LOAD_GLOBAL. Python searches the module and finds the name.

In bump() the same lookup is LOAD_FAST_CHECK: a read from one of the function’s local slots, plus a check that the slot holds something. It’s empty, so that’s the instruction that raises. The last line says the same thing in fewer words. co_varnames, the list of locals fixed at compile time, is empty for read and holds count for bump.

The 0 next to LOAD_FAST_CHECK and STORE_FAST is a slot number. A function’s locals live in an array, and the compiler hands every local name its position ahead of time. At run time there’s no lookup by name, just a read from a fixed index. That’s why “local or global” has to be settled before the first call and can’t depend on which branch runs.

The message is precise about it. Python isn’t saying the name doesn’t exist. It’s saying the local variable with that name has no value yet.

Three versions of the same bug

Beginner code usually hits this in one of three ways.

The first is a counter. count += 1 looks like changing an existing variable, but to the compiler it’s an assignment, since it means count = count + 1:

  File "...\counter.py", line 4, in bump
    count += 1
    ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

The second is a variable assigned in only one branch. No global is involved, and it’s the nastiest of the three, because it works for some inputs:

def grade(score):
    if score >= 50:
        result = "pass"
    return result

print(grade(80))
print(grade(35))

The first call prints pass. The second one dies on return result with the same message about result. Tests that only use passing scores go green, and the bug waits for the first score under the threshold.

The third is a nested function trying to change a variable in the enclosing one. You’ll write this whenever a function has to remember something between calls, like the next invoice number or how many times a button was clicked, without reaching into module globals:

def make_counter():
    state = 0
    def next_value():
        state += 1
        return state
    return next_value

Calling next_value() raises the same error about state. Same rule: state += makes state local to the inner function and cuts it off from the outer variable with the same name.

I got the identical message from Python 3.12 installed alongside. The only difference is cosmetic: the older version doesn’t draw the ~~~~^^ underline beneath the call on the line above.

More things count as assignment than you’d think

= isn’t the only way to make a name local. A for loop assigns too, and it can break a builtin:

def report(items):
    print("Items:", len(items))
    for len in items:
        print(len)

report(["a", "b"]) fails on the function’s first line with cannot access local variable 'len'. That one’s genuinely confusing, because len isn’t a variable you made, it’s a builtin you’ve used since day one. The loop further down turned len into a local, so the builtin is invisible across the whole body of report.

An import inside a function does the same. There’s usually a reason for it: someone moves a slow module into the function so the program starts faster, and forgets the name is already used higher up. If the module imports json at the top and the function has its own import json in some branch, every earlier json.dumps in that function raises the same error for json. I checked it on the same interpreter.

The full list of things that bind a name also includes except ... as, with ... as, del, and a nested def or class.

What a linter catches, and what only a test does

Most of these don’t need a run to find. Ruff 0.17.2 with its default rules reports them as F823:

F823 Local variable `price` referenced before assignment
 --> checkout.py:4:37
  |
3 | def total(discount):
4 |     print("Price before discount:", price)
  |                                     ^^^^^
5 |     if discount:
6 |         price = price * 0.9
  |

Ruff shows line 6 straight away, the assignment the traceback never mentions.

It said nothing about grade.

Static analysis can’t know what score will be. The assignment inside the if might run, so as far as the linter’s concerned the code is fine, and the failure depends entirely on the data. Only a test that passes a score below the threshold will catch it. Here are two tests under pytest 9.1.1, one with 80 and one with 35:

    def grade(score):
        if score >= 50:
            result = "pass"
>       return result
               ^^^^^^
E       UnboundLocalError: cannot access local variable 'result' where it is not associated with a value

grade.py:4: UnboundLocalError
=========================== short test summary info ===========================
FAILED test_grade.py::test_below_threshold - UnboundLocalError: cannot access...
1 failed, 1 passed in 0.06s

The first test passes. Had it been the only one, the function would’ve shipped with the bug inside. pytest also prints score = 35, the value that skipped the branch, which is the fastest way to the cause.

Fixes, best first

The most common advice online is “add global”. It works. Reach for it last.

With the counter, pass the value in and return the result. The function stops depending on module state:

def bump(count):
    return count + 1

count = 0
count = bump(count)
count = bump(count)
print(count)

It prints 2. You can test a function like that with one call, and you don’t have to remember how many times it ran before.

In the branch case, give the variable a value on every path, before the if or in an else. Here the exception is pointing at a real hole in the logic: the function doesn’t know what to return for a failing score, and that’s a decision to make, not a crash to paper over. With result = "fail" set before the if, both tests pass.

For the nested function, there’s the nonlocal keyword, which tells the compiler the name belongs to the enclosing function:

def make_counter():
    state = 0
    def next_value():
        nonlocal state
        state += 1
        return state
    return next_value

c = make_counter()
print(c(), c(), c())

That prints 1 2 3, and every counter from make_counter() gets its own state.

global does the same for a module variable, and it does make the first example’s exception go away. So I checked what happens next. Same function with global price added, called twice with the discount and once without:

Price before discount: 100
90.0
Price before discount: 90.0
81.0
Price before discount: 81.0
81.0

The second discount came off a price that was already discounted, and the third call, with no discount at all, returns 81 instead of 100. No exception anywhere. The program prints wrong numbers and nothing points at where it went wrong.

That friction is deliberate. Picture assignments inside functions quietly changing module variables: any helper variable with a common name would overwrite whatever shares its name in a completely different part of the program. So Python makes you announce it with global or nonlocal, and that leaves a mark in the code you can see when you read it.

Reading this traceback next time

Take the name from the message and search the whole function it happened in, not just the underlined line. You’re looking for anything that binds that name: name =, name +=, for name in, import name, as name. One of them is the cause, often below the read or in a branch you weren’t thinking about.

When the function’s long and searching doesn’t turn it up, put print(locals()) right before the failing line. A name that’s in the error message but missing from that dictionary is the local without a value.

Then decide what you actually want. If the variable should be local, give it a value before the first read. If it should come from outside, pass it in as a parameter. And if your editor runs a linter, F823 shows up before you ever run the program.

Variable scope and reading tracebacks are what the Functions and Debugging chapters cover in the Python course curriculum, and the exercises there run on a real interpreter, with the debugger and tests built into the app.

Topics: Errors and messagesDebugging

The download has started

DevJourney.for.Python_1.1.1_x64-setup.exe · 1.1.1

If your system warns you, and how to check the file

If Windows warns you

"Windows protected your PC" is not a virus detection. SmartScreen trusts the certificate an installer is signed with, and this one is still earning its reputation through a download count.

In the warning: More info → Run anyway.

Confirm it yourself

Check this download on VirusTotal ↗

Or in PowerShell, where you downloaded it:

Get-FileHash .\DevJourney.for.Python_1.1.1_x64-setup.exe

Should print:

e78d7a73363185afc1516777a53ae47c30937c4c70e97a340fdb10896ece86dc