How Python Executes Code Under the Hood
Think of Python scanning a function's entire body for assignments to determine local variables before any code runs. If an assignment exists, that variable is marked local for the whole function, even if it's used before the assignment.
The Setup
You are deploying a hotfix to a production microservice. A syntax check passes during building, but you notice execution crashes only when a specific, rarely accessed code path is hit due to a runtime compilation quirk.
What Does This Print?
x = 10
def run_calculation():
# The developer expects this to print the global x first, then override it locally.
print(x)
x = 20
run_calculation()
The Output
This exception occurs because Python compiles the entire function body as a single unit before executing any of its lines. The compiler detects an assignment to x inside the body of run_calculation(). Consequently, it marks x as local to the function's scope. When the interpreter runs the function, it attempts to read x in the print statement, finds that the local namespace has a slot for x but no bound value, and raises an exception. It does not fall back to the global variable x because the variable has already been classified as local at compile-time.
Why Python Does This
When CPython compiles source code to bytecode, it analyzes scopes statically via a symbol table. If an identifier is bound (assigned to) anywhere within a function, CPython flags it as local for the entire scope, generating the LOAD_FAST bytecode instruction instead of LOAD_GLOBAL. During execution, LOAD_FAST retrieves the variable's value from the local stack frame by index. Because the assignment occurs later in the bytecode, the slot in the stack frame remains empty when the print statement executes, triggering the unbound error. This static analysis step improves performance by avoiding expensive runtime namespace dictionary lookups for local variables.
The Fix
x = 10
# Preferred fix: pass x as a parameter — no scope ambiguity
def run_calculation(value):
print(value) # unambiguously the local parameter, prints 10
value = 20 # modifies only the local copy
run_calculation(x)
# If you genuinely need to rebind a global (rare — treat as a code smell):
# def run_calculation():
# global x # explicit declaration required
# print(x)
# x = 20
To modify a global variable, you must explicitly declare it with the global keyword inside the function. This tells the compiler not to create a new local binding but to use the existing global name instead.
How This Fails in Real Systems
A payment gateway service processed legacy transactions. A developer attempted to modify a global configuration flag inside a conditionally executed block. The code worked in staging because the conditional block was triggered, but failed with UnboundLocalError in production on normal paths because the local assignment compiled the configuration variable as local, breaking all preceding reads. It took 3 hours to locate.