Section · 01
The four scopes Python looks in
When you reference a name, Python searches four places in this order — the “LEGB” rule:
L — Local : names defined inside the current function
E — Enclosing : names in the function that wraps this one (if any)
G — Global : names at the top level of the module
B — Built-in : names that come with Python itself (len, print, range, ...)Python uses the first match it finds. Hit the end of the four without a match? NameError.
page_size = 25 # GLOBAL
def show_page(items, n):
cutoff = n * page_size # LOCAL n, LOCAL cutoff, GLOBAL page_size
for item in items[:cutoff]: # BUILT-IN range/print via for; LOCAL items
print(item)Section · 02
Local scope: assignment makes a new variable
The rule that surprises everyone at least once: assigning to a name inside a function creates a local variable, even if the same name exists at module scope.
count = 0 # global
def increment():
count = count + 1 # FAILS — UnboundLocalError
increment()The error says local variable 'count' referenced before assignment. Python saw count = ... somewhere in the function and decided count is local. Then on the right-hand side it tried to use that local variable before it had a value.
Two clean ways to fix it. Pick the second one almost every time:
# Option A — declare it global (works, but smelly)
count = 0
def increment():
global count
count = count + 1
# Option B — pass it in, return the new value (preferred)
def increment(count):
return count + 1
count = 0
count = increment(count)Option B is testable in isolation, has no hidden dependencies, and works the same way every time. Option A binds the function to a specific global, so you can’t reuse it, can’t test it without setting up the global first, and any other function touching count can break it.
Section · 03
Enclosing scope and nonlocal
A function inside another function can readthe outer function’s variables. To write them, you need nonlocal:
def make_counter():
count = 0 # in the ENCLOSING scope
def tick():
nonlocal count # I want to write the outer 'count'
count = count + 1
return count
return tick
counter = make_counter()
counter() # 1
counter() # 2
counter() # 3This pattern — a function that returns another function which remembers state — is called a closure. You don’t need to write one on day one. Recognize the pattern when you see it; reach for a class once it gets more complicated than this.
global and nonlocal are different: globalmeans “the module-level name,” nonlocalmeans “the enclosing function’s name.” Both are uncommon. If you’re using either weekly, your design probably wants a class.
Section · 04
Module scope (a.k.a. global)
Variables defined at the top of a .py file — outside any function or class — live in module(global) scope. They’re visible to everything inside the file.
# config.py
API_URL = "https://api.yorksims.com" # global to this module
TIMEOUT = 30
DEBUG = False
def fetch():
print(f"GET {API_URL} (timeout={TIMEOUT})") # both are visibleOne thing to keep in mind: “global” in Python means “module-level,” not “visible to the whole program.” Each module has its own globals. If you want something from another module, you import it (next lesson).
The narrow case where globals are fine
True constants — values that never change at runtime — are fine at module scope. Convention says SCREAMING_SNAKE_CASE so readers can tell:
MAX_RETRIES = 5
DEFAULT_PAGE_SIZE = 25
SECONDS_IN_DAY = 60 * 60 * 24Mutable globals that change over time — counters, caches, “the current user” — are where bugs live. Pass state through function arguments, or wrap it in a class. Don’t scatter it across the module.
Section · 05
Why globals are almost always a smell
Two short examples to make the case:
# 1. UNTESTABLE
ACTIVE_USER = None
def get_dashboard():
return f"Hi, {ACTIVE_USER.name}." # depends on hidden state
# To test get_dashboard, you have to set ACTIVE_USER first. Forget to
# reset it between tests and you'll get bizarre failures. The function
# CAN'T be reasoned about in isolation — its behavior depends on whatever
# happened to set ACTIVE_USER somewhere else.# 2. RACE-PRONE
counter = 0
def handle_request():
global counter
counter = counter + 1
# Two threads / async tasks calling handle_request at once can step on
# each other — both read counter, both add 1, both write the same value.
# You "incremented twice" but the number only went up by one.The fix in both cases is the same: take the data as an argument, return the new value, or wrap the state in an object the caller owns. Once you stop hiding state behind globals, your functions become small, testable, and obviously correct. Same code, fewer surprises.