Names, binding, and scope
Python doesn't have variables. It has names that are bound to objects. This distinction matters.
Names are not variables
When you write x = 5, you're not storing 5 in a box labeled x. Instead you're creating a binding, which is a name tag attached to the object 5.
x = 5
y = x
What happened? Both x and y now point to the same object 5. There's no copying, and no storing. These are just two names for the same thing.
x = 5
y = x
x = 10
print(y) # Still 5
Why? Because x = 10 doesn't change the object 5. It creates a new binding. x now points to 10. The 5 object still exists, and y still points to it.
This mental model explains everything that follows.
Assignment is binding, not mutation
Binding changes which object a name points to; mutation changes the object itself. In other words, binding moves a label from one box to another box, while mutation changes the contents inside the box while the label stays on the same box.
x = [1, 2, 3] # Binding
x = [4, 5, 6] # New binding
x.append(7) # Mutation
The first line creates a list object and binds x to it. The second line creates a different list object and rebinds x. The third line mutates the object that x is bound to.
This is why this works:
def add_one(x):
x = x + 1 # Rebinding the local name
n = 5
add_one(n)
print(n) # Still 5
And this works differently:
def append_item(lst):
lst.append(4) # Mutating the object
my_list = [1, 2, 3]
append_item(my_list)
print(my_list) # [1, 2, 3, 4]
Same function call pattern. Different behavior. Because one rebinds, the other mutates.
LEGB: How Python finds names
Python looks up names in this order:
- Local — names defined in the current function
- Enclosing — names in enclosing functions (closures)
- Global — names at module level
- Built-in — names like
len,print,int
Python looks up names at runtime, but which scope a name belongs to is determined when the function is compiled.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
return x
return inner()
outer() # "local"
Python looks in inner() first, finds x, and stops. It never checks enclosing or global.
But what if inner() doesn't define x?
x = "global"
def outer():
x = "enclosing"
def inner():
return x # No local x
return inner()
outer() # "enclosing"
Now Python looks in inner(), finds nothing, looks in outer(), finds x, and stops.
This is lexical scoping—Python uses where the code is written, not where it's called.
global: Breaking the LEGB rule
global tells Python: "when you see this name, skip Local and Enclosing and go straight to Global."
x = 1
def func():
global x
x = 2 # Modifies the global x
func()
print(x) # 2
Without global:
x = 1
def func():
x = 2 # Creates a local binding
return x
func() # 2
print(x) # 1 (global unchanged)
global doesn't "bring the variable into the function." It changes where Python looks for the name.
nonlocal: The middle ground
nonlocal says: "skip Local, but use the nearest Enclosing scope (not Global)."
def outer():
x = 1
def inner():
nonlocal x
x = 2 # Modifies outer's x
inner()
return x
outer() # 2
Without nonlocal:
def outer():
x = 1
def inner():
x = 2 # Creates a local binding
return x
inner() # 2
return x
outer() # 1 (outer's x unchanged)
nonlocal lets you modify enclosing scope without reaching all the way to global.
Closures: Names, not values
A closure captures a name, not a value. The name is looked up when the function is called, not when it's created.
def make_func():
x = 1
def inner():
return x
x = 2 # Changed before returning
return inner
f = make_func()
f() # 2, not 1
The closure captured the name x. When f() runs, it looks up x in the enclosing scope and finds 2.
This is why this classic gotcha happens:
funcs = []
for i in range(3):
funcs.append(lambda: i)
# What do these return?
funcs[0]() # 2
funcs[1]() # 2
funcs[2]() # 2
All three functions capture the name i. When they're called, i has the value 2 (the last value from the loop).
Each lambda doesn't get its own copy of i. They all share the same name.
Fixing the loop closure problem
You need to capture the value, not the name. Create a new binding for each iteration:
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # Default argument creates new binding
funcs[0]() # 0
funcs[1]() # 1
funcs[2]() # 2
Default arguments are evaluated at function definition time. Each lambda gets its own i parameter bound to the current loop value.
Or use a closure factory:
def make_func(n):
return lambda: n
funcs = []
for i in range(3):
funcs.append(make_func(i))
funcs[0]() # 0
funcs[1]() # 1
funcs[2]() # 2
Each call to make_func(i) creates a new scope where n is bound to the current i value.
The mental model
- Names are tags on objects, not boxes storing values
- Assignment binds names to objects, it doesn't copy or store
- LEGB is lookup order—Python searches scopes in this order
- Closures capture names, which are resolved when called
globalandnonlocalchange lookup behavior, not data flow
Everything else follows from these principles.