## Summary

Python decides the scope of the variable **ahead of time**. **Unless explicitly overridden** using the [`global`](/questions/423379) or [`nonlocal`](/questions/1261875) (in 3.x) keywords, variables will be recognized as **local** based on the **existence of any** operation that would *change the binding of* a name. That includes ordinary assignments, augmented assignments like `+=`, various less obvious forms of assignment (the `for` construct, nested functions and classes, `import` statements...) as well as *un*binding (using `del`). The actual execution of such code is irrelevant.

This is also explained [in the documentation](https://docs.python.org/3/faq/programming.html#why-am-i-getting-an-unboundlocalerror-when-the-variable-has-a-value).

## Discussion

Contrary to popular belief, **Python is not an "interpreted" language** in any meaningful sense. (Those are vanishingly rare now.) The reference implementation of Python compiles Python code in much the same way as Java or C#: it is translated into opcodes ("bytecode") for a *virtual machine*, which is then emulated. Other implementations must also compile the code; otherwise, `eval` and `exec` could not properly return an object, and `SyntaxError`s could not be detected without actually running the code.

### How Python determines variable scope

During compilation (whether on the reference implementation or not), Python [follows simple rules](https://stackoverflow.com/questions/291978/) for decisions about variable scope in a function:

* If the function contains a [`global`](https://stackoverflow.com/questions/423379/) or [`nonlocal`](https://stackoverflow.com/questions/1261875/) declaration for a name, that name is treated as referring to the global scope or the first enclosing scope that contains the name, respectively.

* Otherwise, if it contains any *syntax for changing the binding (either assignment or deletion) of the name, even if the code would not actually change the binding at runtime*, the name is **local**. 

* Otherwise, it refers to either the first enclosing scope that contains the name, or the global scope otherwise.

Importantly, the scope is resolved **at compile time**. The generated bytecode will directly indicate where to look. In CPython 3.8 for example, there are separate opcodes `LOAD_CONST` (constants known at compile time), `LOAD_FAST` (locals), `LOAD_DEREF` (implement `nonlocal` lookup by looking in a closure, which is implemented as a tuple of "cell" objects), `LOAD_CLOSURE` (look for a local variable in the closure object that was created for a nested function), and `LOAD_GLOBAL` (look something up in either the global namespace or the builtin namespace).

**There is no "default" value for these names**. If they haven't been assigned before they're looked up, a `NameError` occurs. Specifically, for local lookups, `UnboundLocalError` occurs; this is a subtype of `NameError`.

### Special (and not-special) cases

There are some important considerations here, keeping in mind that the syntax rule is implemented at compile time, with **no static analysis**:

* It **does not matter** if the code could never be reached:
 ```
 y = 1
 def x():
 return y # local!
 if False:
 y = 0
 ```
* It **does not matter** if the assignment would be optimized into an in-place modification (e.g. extending a list) - conceptually, the value is still assigned, and this is reflected in the bytecode in the reference implementation as a useless reassignment of the name to the same object:
 ```
 y = []
 def x():
 y += [1] # local, even though it would modify `y` in-place with `global`
 ```
* However, it **does** matter if we do an indexed/slice assignment instead. (This is transformed into a different opcode at compile time, which will in turn call `__getitem__`.)
 ```
 y = [0]
 def x():
 print(y) # global now! No error occurs.
 y[0] = 1
 ```
* There are other forms of assignment, e.g.:
 ```
 y = 1
 def x():
 return y # local!
 for y in []:
 pass
 ```
* Deletion is also changing the name binding, e.g.:
 ```
 y = 1
 def x():
 return y # local!
 del y
 ```
The interested reader, using the reference implementation, is encouraged to inspect each of these examples using the `dis` standard library module.

### Enclosing scopes and the `nonlocal` keyword (in 3.x)

The problem works the same way, *mutatis mutandis*, for both `global` and `nonlocal` keywords. (Python 2.x [does not have `nonlocal`](https://stackoverflow.com/questions/3190706/).) Either way, the keyword is necessary to assign to the variable from the outer scope, but is *not* necessary to *merely look it up*, nor to *mutate* the looked-up object. (Again: `+=` on a list mutates the list, but *then also reassigns* the name to the same list.)

### Special note about globals and builtins

As seen above, Python does not treat any names as being "in builtin scope". Instead, the builtins are a fallback used by global-scope lookups. Assigning to these variables will only ever update the global scope, not the builtin scope. However, in the reference implementation, the builtin scope **can** be modified: it's represented by a variable in the global namespace named `__builtins__`, which holds a module object (the builtins are implemented in C, but made available as a standard library module called `builtins`, which is pre-imported and assigned to that global name). Curiously, unlike many other built-in objects, this module object can have its attributes modified and `del`d. (All of this is, to my understanding, supposed to be considered an unreliable implementation detail; but it has worked this way for quite some time now.)