## 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.)