Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 23 of 898 min

Where a name lives, and why the function cannot see it

Four places Python looks for a name

When you use a name, Python searches in this order and stops at the first hit:

  1. Local — names assigned inside the current function.
  2. Enclosing — names in a function that contains this one.
  3. Global — names at the top level of the module.
  4. Built-inprint, len, sum and the rest.

That is the LEGB rule, and it explains both directions of confusion.

Reading a global from inside a function works without ceremony:

python
TAX_RATE = 0.18

def with_tax(amount):
    return amount * (1 + TAX_RATE)     # fine, found at step 3

Assigning changes everything

python
count = 0

def bump():
    count = count + 1     # UnboundLocalError

bump()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

The reason is precise and worth knowing. When Python compiles the function, it scans the whole body. Because count is assigned somewhere in it, count is marked local for the entire function — including the line that tries to read it before the assignment. The global count becomes invisible inside bump, so the read on the right-hand side has nothing to fetch.

How Python decides that a name is localdef is compiledThe whole bodyis scannedbefore any of itrunsAssignedanywhere in thebody?One assignmentcounts, even onthe last lineYes: local forall of itUnless global ornonlocal saysotherwiseRead beforethat lineUnboundLocalError,not NameErrorNo assignmentat allLooked upoutward as itruns: local,enclosing,global, built-inThis is a compile-time decision made from the text of the function, which is why the assignment can beon the last line and still make the name local on the first. UnboundLocalError therefore means theopposite of what it sounds like: not that the name is missing, but that Python knows it is local andknows it has no value yet.
How Python decides that a name is localdef is compiledThe whole body is scanned before any of it runsAssigned anywhere in the body?One assignment counts, even on the last lineYes: local for all of itUnless global or nonlocal says otherwiseRead before that lineUnboundLocalError, not NameErrorNo assignment at allLooked up outward as it runs: local, enclosing,global, built-inThis is a compile-time decision made from the textof the function, which is why the assignment can beon the last line and still make the name local onthe first. UnboundLocalError therefore means theopposite of what it sounds like: not that the nameis missing, but that Python knows it is local andknows it has no value yet.

The surprising part is that this happens even when the assignment comes later:

python
def report():
    print(TAX_RATE)     # UnboundLocalError, despite line 1 looking harmless
    TAX_RATE = 0.05

A single assignment anywhere in the body decides the name's scope for all of it. This is a compile-time decision, not a runtime one.

global, and why to avoid needing it

python
count = 0

def bump():
    global count
    count += 1

That works. It also means any function anywhere can now change count, and when the value is wrong you have to read the whole program to find out who changed it. Every additional global roughly doubles the search space when debugging.

Better shapes, in order of preference:

python
def bump(count):
    return count + 1        # take it in, hand it back

count = bump(count)

or keep the state in an object, or in a dictionary passed explicitly. Module-level constants in capitals, read but never assigned, are fine and normal; global for mutable state is the thing to avoid.

Mutation slips through without global

python
totals = []

def record(x):
    totals.append(x)      # works — no `global` needed

No error, and the list really does change. The rule from the previous lesson applies: totals.append(x) does not assign to the name totals, it calls a method on the object the name already refers to. Only assignment triggers the local-name rule.

So totals = [] inside the function would need global, and totals.append(x) does not. That asymmetry catches people who learned the global rule as "you cannot change globals from a function".

nonlocal, for nested functions

python
def counter():
    n = 0
    def increment():
        nonlocal n
        n += 1
        return n
    return increment

nonlocal says "the name in the enclosing function, not a new local and not a module global". Without it, n += 1 would be an UnboundLocalError inside increment. You meet this in closures and decorators, both of which appear later in the course.

Loops and if do not create a scope

Unlike most languages with braces, Python only creates a new scope for a function, a class, or a module. A variable first assigned inside a for or an if is visible after it:

python
for row in rows:
    last = row
print(last)        # works — unless rows was empty, then NameError

That is convenient and it produces one specific bug: if the loop ran zero times, the name was never created, and the line after the loop raises NameError rather than giving you an empty result. Initialise before the loop when the code after it depends on the name.

Shadowing a built-in

python
list = [1, 2, 3]
list("abc")        # TypeError: 'list' object is not callable

Assigning to list, dict, sum, id, type or input hides the built-in for the rest of the module. Nothing warns you at the moment you do it; the failure comes later, somewhere else, in code that had every right to expect list to be a type. Add a trailing underscore — list_ — or pick a better name.

Free linters catch this in one command: ruff check flags shadowed built-ins, unused names and the loop-variable problems above, and it runs in under a second on a whole project.

The one thing to keep

A name assigned anywhere in a function is local for the whole function, which is why reading a global before assigning it raises UnboundLocalError, while mutating a global object needs no declaration at all.

Before you move on

A function reads `config` on its first line and, twenty lines later, does `config = {}` in an error branch that has never yet run. Every call now fails on line 1 with UnboundLocalError. Why does an unreached line break the first line?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly