How to Fix UnboundLocalError: local variable referenced before assignment in Python

3D isometric tech render of a function scope chamber blocking a global variable connection until an initial default value locks into the local variable slot.

Defining count = 0 at the top of your file and writing count += 1 inside a function looks completely harmless—until Python crashes before executing a single math operation. Even more confusingly, a function with an if / elif block can run fine for months and suddenly halt with UnboundLocalError the moment an unexpected input skips both branches.

UnboundLocalError (a direct subclass of NameError) happens because Python decides variable scope at compile time, not at runtime. If you assign to (or del) a variable anywhere inside a function—even on the very last line or inside a branch that never runs—Python marks that variable as strictly local to the entire function and refuses to fall back to a global or enclosing variable of the same name.

Reading the UnboundLocalError Traceback (Python 3.10 vs. 3.11+)

Read the traceback from the bottom up. The final line names the exact local variable Python tried to read before assigning it a value:

Traceback (most recent call last):
  File "/var/www/app/main.py", line 6, in <module>
    print(get_discount("bronze"))
  File "/var/www/app/main.py", line 4, in get_discount
    return discount
UnboundLocalError: local variable 'discount' referenced before assignment
  • Python 3.11+ Message Change: Starting in Python 3.11+ (not run by me; noted from documentation), CPython reworded this exact exception message to:UnboundLocalError: cannot access local variable 'discount' where it is not associated with a value and draws ^^^^^^^^ carets directly under the unbound variable name. Both messages mean the exact same thing.

Tested Environment: Python 3.10.16 (Linux) | Python 3.11+ wording/carets and linter behaviors not run by me; noted from documentation.

Part of: The Ultimate Python Debugging Guide

⚡ The Quick Fix: Initialize Defaults, Validate Branches, or Pass Arguments

Choose your fix based on whether the variable is an optional local value, a required local value, or an outer variable you intended to update:

1. Optional Local Values: Initialize a Sensible Default at the Top of the Function If you only assign a variable inside an if block, for loop, or try block, define its fallback value (0.0, None, or "") before the block starts:

def get_discount(tier):
    discount = 0.0  # Safe default if no branch matches
    if tier == "gold":
        discount = 0.20
    elif tier == "silver":
        discount = 0.10
    return discount

print(get_discount("bronze"))  # Prints: 0.0

2. Required Local Values: Validate the Input and Raise a Meaningful Error

If every call must match a valid branch—and a silent default like 0.0 or None would corrupt downstream math—add an else: clause that raises a descriptive ValueError immediately:

def get_discount(tier):
    if tier == "gold":
        discount = 0.20
    elif tier == "silver":
        discount = 0.10
    else:
        raise ValueError(f"Unsupported membership tier: {tier!r}")
    return discount

3. Updating an Outer Variable: Pass It as an Argument and return It

If you tried to modify a module-level variable using count += 1, pass the variable into the function and return the new value (or declare global count / nonlocal count if you must mutate outer state in place):

count = 0

# Cleanest approach: pure function (no hidden global mutation)
def increment(current_count):
    return current_count + 1

count = increment(count)

Inspect How Python’s Compiler Scoped Your Function

Why does print(count) work fine inside a function on its own, yet crash on the very same line if you add count += 1 or del count below it?

When Python compiles a def block, it stores local variable names in __code__.co_varnames and global lookups in __code__.co_names. You can inspect this compile-time decision directly:

count = 0

def increment():
    print(count)
    count += 1

print("Local variables (co_varnames):", increment.__code__.co_varnames)
print("Global lookups  (co_names):   ", increment.__code__.co_names)

Verified Output (Python 3.10.16):

Local variables (co_varnames): ('count',)
Global lookups  (co_names):    ('print',)

Because count += 1 assigns to count on line 5, the compiler placed 'count' into co_varnames (local scope) for the entire increment() function. When print(count) runs on line 4, Python looks in the local slot 'count', sees that you haven’t assigned a value to it yet, and raises UnboundLocalError without ever checking the global count = 0.

Common Causes of UnboundLocalError

Here are the eight most frequent ways local variables end up referenced before assignment.

1. Conditional if / elif Blocks Without an else or Default

When a variable is created inside an if or elif branch, Python registers the name as a local variable for the whole function. If the runtime condition is False and there is no else branch (or top-of-function default), the variable remains unassigned when the function tries to return or print it.

  • The Trigger (Verified 3.10.16):
def get_discount(tier):
    if tier == "gold":
        discount = 0.20
    elif tier == "silver":
        discount = 0.10
    return discount

print(get_discount("bronze"))
# UnboundLocalError: local variable 'discount' referenced before assignment
  • The Fix: Initialize discount = 0.0 before the if statement when a default is appropriate, or add an else: raise ValueError(...) branch when the input must match a known option.
  • How to tell it’s this one: The variable in the traceback is only assigned inside an if or elif block above the crashing line.

2. Augmented Assignment (+=, -=, *=) on a Global Variable

In Python, count += 1 is syntactic sugar for count = count + 1. Because the left side is an assignment, Python treats count as a local variable inside the function and immediately fails while trying to read count on the right side.

  • The Trigger (Verified 3.10.16):
count = 0

def increment():
    count += 1  # Fails: local variable 'count' referenced before assignment

increment()
  • The Fix: Pass count as a parameter and return the updated value (return count + 1), or add global count at the top of the function if mutating a module-level variable is required.
  • How to tell it’s this one: The crash happens directly on a line containing +=, -=, *=, or x = x + 1 where x was defined at the top of your file.

3. The Nested Function (Closure) nonlocal Trap

When you define a helper function inside another function (such as a decorator, callback, or stateful closure) and try to reassign or increment a variable from the outer function, Python raises UnboundLocalError. Worse yet, if you try to fix it by adding global total, Python skips the outer function, looks at the module level, and throws NameError: name 'total' is not defined!

  • The Trigger (Verified 3.10.16):
def make_tracker():
    total = 0
    def add_score(points):
        total += points  # Fails: local variable 'total' referenced before assignment
        return total
    return add_score

tracker = make_tracker()
tracker(10)

The Fix: Declare nonlocal total inside the inner function. nonlocal instructs Python’s compiler to bind total to the nearest enclosing function scope rather than creating a new local variable or searching the global module scope:

def make_tracker():
    total = 0
    def add_score(points):
        nonlocal total  # Binds to 'total' in make_tracker()
        total += points
        return total
    return add_score
  • How to tell it’s this one: You have a def nested inside another def, and the variable lives in the outer function.

4. The Class Scope Trap (Class Bodies Are Not Enclosing Scopes)

Developers coming from Java or C++ often assume a class body acts like an enclosing scope for its methods. In Python’s LEGB scope rules, class bodies do not form an enclosing (E) scope for methods. If you define count = 0 inside a class and write count += 1 inside a method without self. or the class name, Python treats count as an unassigned local variable and raises UnboundLocalError (and writing nonlocal count fails with SyntaxError: no binding for nonlocal 'count' found).

  • The Trigger (Verified 3.10.16):
class Counter:
    count = 0

    def increment(self):
        count += 1  # Fails: UnboundLocalError: local variable 'count' referenced before assignment

c = Counter()
c.increment()
  • The Fix: Access class or instance attributes explicitly through self.count += 1 (to update the attribute on that specific instance) or Counter.count += 1 (to update the shared attribute on the class itself):
class Counter:
    count = 0

    def increment(self):
        self.count += 1     # Updates instance attribute
        Counter.count += 1  # Updates shared class attribute
  • How to tell it’s this one: The crash happens inside a class method on a variable that was defined at the top of the class block without self..

5. Variable Assigned Inside a try Block That Raises an Exception Early

A classic production trap is assigning data = json.loads(raw) inside a try block, catching the exception in except: with a log message (without returning or re-raising), and then referencing data after the try/except block.

  • The Trigger (Verified 3.10.16):
import json

def parse_payload(raw):
    try:
        data = json.loads(raw)
    except ValueError:
        print("Invalid JSON")
    return data  # Fails if json.loads() raised ValueError!

parse_payload("{bad json}")
  • The Fix: Either return / raise directly inside the except block, initialize data = None before try:, or place code that depends on data inside a try ... except ... else: block (the else: block only runs if try: succeeded without exceptions).
  • How to tell it’s this one: Your console prints an error log from an except block right before crashing with UnboundLocalError a few lines below it.

6. Shadowing an Imported Module or Built-In Function Later in the Function

If you call json.loads(raw) on line 4 of a function, and then assign a local variable named json = ... (or write import json inside an if branch) on line 15 of the same function, Python marks json as a local variable from line 1 of that function.

  • The Trigger (Verified 3.10.16):
import json

def format_user(raw):
    parsed = json.loads(raw)  # Fails: 'json' is treated as an unset local variable!
    json = {"status": "ok", "user": parsed}
    return json

format_user('{"name": "Alice"}')
  • The Fix: Never reuse the name of an imported module (json, os, time, re) or built-in function (list, str, id, max) as a local variable name. Rename the local variable to json_data or payload, and keep all import statements at the top of the file.
  • How to tell it’s this one: The variable named in UnboundLocalError: local variable 'json' referenced before assignment is actually a module you imported at the top of your file!

7. Referencing an Unbound Exception Variable or Loop Variable

Python physically unbinds a variable from memory in two distinct scenarios, leading to this error:

  1. The except ... as err Deletion: In Python 3, the target variable bound by except ValueError as err: is automatically deleted at the end of the except block (as if Python injected del err in a hidden finally clause) to prevent memory leaks from traceback reference cycles.
  2. The Empty Loop: A variable assigned only inside a for loop (for item in items: last = item) remains unbound if the iterable (items) is an empty list [].
  • The Trigger (Exception Variable Deletion — Verified 3.10.16):
def check_val(x):
    try:
        int(x)
    except ValueError as err:
        pass
    print(err)  # Fails: 'err' was unbound when the except block exited!

check_val("abc")
  • The Trigger (Empty for Loop — Verified 3.10.16):
def find_last(items):
    for item in items:
        last = item
    return last  # Fails: 'last' is unbound because 'items' was empty

find_last([])
  • The Fix:
  • For exceptions, handle or log err inside the except block, or save it to a separate variable initialized before try:.
  • For loops, initialize the variable (last = None) before the for loop starts.

How to tell it’s this one: The variable name in the traceback (e, err, exc) matches the as ... variable of an except clause above it, or the traceback points to a variable that is only assigned inside a for loop.

8. The del Statement Trap (del x Counts as a Local Binding)

Python’s compiler treats del x as a variable-binding operation because it modifies the namespace by unbinding x. If you place del cache at the bottom of a function without declaring global cache, Python marks cache as a local variable for the entire function—causing an UnboundLocalError if you try to read cache at the top of the function, or on the del cache line itself!

  • The Trigger (Verified 3.10.16):
cache = "active"

def reset():
    print(cache)  # Fails: local variable 'cache' referenced before assignment!
    del cache     # Because of 'del', Python treats 'cache' as local to reset()

reset()
  • The Fix: Only use del on local variables that were already assigned inside that same function, or use dictionary/list mutation (cache.clear()) instead of deleting global variable names. If you genuinely need to unbind a module-level variable, declare global cache first.
  • How to tell it’s this one: You have a del var_name statement inside your function.

Walrus Operator (:=) Scope Leakage in List Comprehensions

Can the walrus operator (:=, introduced in Python 3.8) bypass UnboundLocalError? No. Writing (count := count + 1) inside a function still binds count as a local variable and raises UnboundLocalError if count wasn’t initialized locally first.

Even more importantly, the walrus operator has a special scope rule inside comprehensions (PEP 572). In Python 3, a list comprehension has its own isolated scope for its loop variable ([x for x in items]), so x does not leak out. However, a variable assigned with := inside a comprehension intentionally leaks into the enclosing function’s local scope—which creates two subtle UnboundLocalError traps:

tax_rate = 0.05

# Trap 1: Leaked walrus target shadows a global variable read earlier!
def compute(prices):
    print("Starting tax_rate:", tax_rate)  # Fails with UnboundLocalError!
    totals = [tax_rate := p * 0.1 for p in prices]
    return totals

# Trap 2: Comprehension over an empty list [] never executes 'last :='
def get_last_doubled(items):
    results = [last := x * 2 for x in items]
    return last  # Fails with UnboundLocalError when items is []!
  • The Fix: Never reuse a global variable name on the left side of := inside a comprehension, and always initialize last = None before the comprehension if the iterable might be empty.

UnboundLocalError vs. NameError vs. Mutating a Global List/Dict

Developers are often surprised that my_list.append(1) and my_dict["hits"] += 1 work inside a function without global, whereas my_list += [1] or count += 1 crashes with UnboundLocalError. Here is how Python distinguishes between mutating an object and rebinding a variable name:

Code Inside FunctionWhat Happens (Verified 3.10.16)Why Python Behaves This Way
print(unknown_var) (not assigned anywhere)NameError: name 'unknown_var' is not definedBecause unknown_var is never assigned in the function, Python searches local → enclosing → global → built-in scopes (LEGB) and raises NameError.
print(count) followed by count = 1 or del countUnboundLocalError: local variable 'count' referenced before assignmentThe assignment count = 1 (or del count) makes count local to the function, blocking the global lookup on the earlier print(count) line.
global_list.append(4) or global_dict["k"] = 1Succeeds (no global keyword needed)Calling .append() or assigning a key ["k"] mutates the existing object in memory without assigning a new object to the variable name.
global_dict["hits"] += 1 or global_list[0] += 1Succeeds (no global keyword needed)Because the target on the left of += is a subscript (["hits"] or [0]), Python calls __getitem__ and __setitem__ on global_dict without rebinding the name global_dict.
global_list += [4]UnboundLocalError: local variable 'global_list' referenced before assignmentEven though += mutates lists in place (__iadd__), the target on the left is the bare variable name global_list, so Python rebinds global_list = ... and marks it local! Use global_list.extend([4]) instead.

Catch Unbound Local Variables Before Runtime with Linters

Because conditional if/elif and try/except bugs only crash when a specific code path is hit in production, static analysis is the best defense (linter behaviors noted from documentation):

  1. Ruff / Flake8 Rule F823 (undefined-local): Enabled by default in both Ruff and Flake8 (via Pyflakes). It immediately flags any local variable referenced before assignment (such as count += 1, del count, or shadowing an imported module like json later in the function).
  2. Pylint Rules E0601, E0606, and W0621:
    • E0601 (used-before-assignment): Flags variables read unconditionally before they are assigned.
    • E0606 (possibly-used-before-assignment): Flags conditional branches (if/elif without else, or try/except fallthroughs) where a variable might be unbound on certain execution paths.
    • W0621 (redefined-outer-name): Warns you when a local variable inside a function has the exact same name as a global variable or import.
  3. Pyright (reportUnboundVariable) & Mypy (possibly-undefined): Both type checkers trace every if/elif/else and try/except branch and flag variables that can reach a return statement unassigned.

Worked Example: Fixing a try/except Fallthrough in a JSON Parser

Let’s walk through a real bug where an error handler logs a bad payload, but forgets that the local variable total was never created.

The Code (/var/www/app/main.py):

import json

def parse_order_total(raw_json):
    try:
        payload = json.loads(raw_json)
        total = float(payload["total"])
    except (ValueError, KeyError) as err:
        print(f"Invalid payload: {err}")
    return round(total, 2)

print(parse_order_total('{"total": "invalid_number"}'))

Verified Output & Traceback (Python 3.10.16):

Invalid payload: could not convert string to float: 'invalid_number'
Traceback (most recent call last):
  File "/var/www/app/main.py", line 11, in <module>
    print(parse_order_total('{"total": "invalid_number"}'))
  File "/var/www/app/main.py", line 9, in parse_order_total
    return round(total, 2)
UnboundLocalError: local variable 'total' referenced before assignment

The Thought Process & Fix:

  1. On line 6, float("invalid_number") raises a ValueError before the assignment to total completes.
  2. Execution jumps into the except block on line 7, prints "Invalid payload...", and then continues to line 9 (return round(total, 2)).
  3. Because total was never assigned a value, line 9 crashes with UnboundLocalError.
  4. The Result: Depending on your application’s requirements, either return a safe fallback inside except (for optional/best-effort parsing) or re-raise a domain error so invalid orders halt immediately:
# Approach A (Optional / Fallback): Return a default value inside except
def parse_order_total(raw_json, default=0.0):
    try:
        payload = json.loads(raw_json)
        total = float(payload["total"])
    except (ValueError, KeyError):
        return default
    return round(total, 2)

# Approach B (Required): Raise a meaningful error so bad data never slips through
def parse_order_total_strict(raw_json):
    try:
        payload = json.loads(raw_json)
        total = float(payload["total"])
    except (ValueError, KeyError) as err:
        raise ValueError(f"Order payload must contain a numeric 'total': {raw_json!r}") from err
    return round(total, 2)

Frequently Asked Questions

Can except NameError: catch an UnboundLocalError?

Yes. In Python’s exception hierarchy, UnboundLocalError is a direct subclass of NameError (issubclass(UnboundLocalError, NameError) is True). However, you should almost never catch UnboundLocalError with try/except—it always indicates a scoping or branching bug in your function that should be fixed in the code.

What is the difference between global and nonlocal in Python?

Use global x inside a function when x is defined at the top-level module scope. Use nonlocal x inside a nested inner function when x is defined in an enclosing outer function. Note that neither global nor nonlocal works for class attributes—always use self.x or ClassName.x inside methods.

Why does my_dict['key'] += 1 work on a global dict without global, while my_list += [1] raises UnboundLocalError?

When you write my_dict["key"] += 1, the target on the left side of += is a subscript (["key"]), not the variable name my_dict. Python looks up my_dict in the global scope and calls my_dict.__setitem__("key", ...) to mutate the dictionary in place. When you write my_list += [1], the target on the left is the bare variable name my_list, which forces Python to rebind my_list = my_list.__iadd__([1]) and marks my_list as a local variable.

Can I bypass UnboundLocalError using globals()['count'] += 1 without the global keyword?

Technically, yes. Because globals() returns a dictionary representing the current module’s global symbol table, writing globals()["count"] += 1 mutates the "count" key inside that dictionary without binding a local variable name. However, using globals()["count"] += 1 to mutate state is considered an anti-pattern because it hides global state mutations from static type checkers, linters, and IDE refactoring tools. The only legitimate reason to read globals()["x"] is when a local function parameter intentionally has the same name as a global variable and you need to read both.

Why did adding del x or an unreachable if False: x = 1 at the bottom of my function break the top of my function?

Python determines whether x is a local variable during compilation by scanning the function’s syntax tree before any code runs. Any assignment (x = 1, x += 1, x := 1) or deletion (del x) anywhere inside the function body instructs the compiler to treat x as a local variable from the very first line of that function.

Similar Posts

Leave a Reply