BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

PY-01-012-P · Primer — Scope & Namespaces

The idea in one sentence

A scope is the region of your code where a name (like x or total) points to a particular value, and Python has clear rules for which value wins when the same name appears in more than one place.

Why it exists

When you write x = 5 inside a function and there is also an x = 99 outside it, Python needs an unambiguous rule to decide which x you mean. Without scopes, every variable in every function would collide with every other, and large programs would be impossible to reason about. Scopes let each function keep its own private set of names — a namespace — so you can reuse a friendly name like count or result in a hundred functions without any of them stepping on each other. They also protect you: a function can read the outside world by default, but can't accidentally overwrite it unless you explicitly ask.

How it works

Python resolves a name by searching four scopes in a fixed order — the LEGB rule: Local (inside the current function), Enclosing (any function wrapping this one), Global (the top level of the file/module), and Built-in (print, len, and friends). The first match wins, and the search stops there. Assignment is the twist: writing to a name (x = ...) makes it local by default, which is why the global keyword lets a function rebind a module-level name, and nonlocal lets an inner function rebind a name from its enclosing function. A closure happens when an inner function "remembers" a variable from its enclosing scope even after the outer function has returned. In the example below, each returned click carries its own live count:

message = "global"          # global scope

def make_counter():
    count = 0               # enclosing scope
    def click():
        nonlocal count      # rebind the enclosing 'count'
        count += 1
        return count
    return click

def announce():
    global message
    message = "changed by announce()"

counter = make_counter()
print(counter())            # 1
print(counter())            # 2
print(message)              # global
announce()
print(message)              # changed by announce()

# Output:
# 1
# 2
# global
# changed by announce()

The one gotcha

Reading a global variable inside a function works with no ceremony — but the moment a function assigns to that same name anywhere in its body, Python treats the name as local for the entire function, even on lines before the assignment. So total += 1 on a global total (without global total) raises UnboundLocalError, because += is really "read then assign," and the read now looks for a local that hasn't been created yet. The fix is to declare global total (or nonlocal for an enclosing name) so Python knows you mean the outer one.

See it — LEGB name resolution, innermost first

flowchart TD
    A["Look up a name<br/>e.g. count"] --> B{"Local scope?<br/>this function"}
    B -->|found| Z["Use this value"]
    B -->|no| C{"Enclosing scope?<br/>outer function"}
    C -->|found| Z
    C -->|no| D{"Global scope?<br/>top of module"}
    D -->|found| Z
    D -->|no| E{"Built-in scope?<br/>print, len, ..."}
    E -->|found| Z
    E -->|no| F["NameError:<br/>name is not defined"]

The one-line takeaway

Names are resolved Local → Enclosing → Global → Built-in, and any assignment makes a name local unless you say global or nonlocal.

Where you'll meet it

You'll hit scope every time you write a helper function, build a counter or accumulator, use a decorator or a factory that returns a function, or debug a surprise UnboundLocalError. It's also the foundation for understanding closures, callbacks, and why mutating a global from deep inside your code is usually a smell worth refactoring.

Named concepts: local vs global scope, the LEGB resolution rule, global and nonlocal keywords, closures over enclosing variables