In most introductions to programming, a variable is a box — a container that holds a value. Python works differently. A Python variable is a name, and that name points to an object. The object lives independently. This one distinction explains why assignment behaves the way it does, why some changes affect multiple variables at once, why functions sometimes modify their arguments and sometimes do not, and why scope rules work the way they do.
All code blocks in this article are runnable Python 3. The output blocks show exactly what each snippet prints.
Names, Not Boxes
When you write x = 42, Python does three things: it creates an integer object with the value 42, it creates a name x in the current namespace, and it binds that name to the object. The name and the object are separate. The object would exist even without the name — it just would not be reachable.
# A name points to an object — inspect both
x = 42
print(f"value : {x}")
print(f"type : {type(x)}")
print(f"id (address): {id(x)}")
# Two names can point to the same object
y = x
print(f"\ny = x")
print(f"x id : {id(x)}")
print(f"y id : {id(y)}")
print(f"same object? {x is y}")
# Rebinding x does NOT affect y — the object 42 is unchanged
x = 100
print(f"\nAfter x = 100:")
print(f"x = {x} (now points to 100)")
print(f"y = {y} (still points to 42)")
print(f"same object? {x is y}")
# The object lives as long as something points to it
# Once nothing does, Python's garbage collector reclaims it
a = [1, 2, 3]
b = a # both names point at the same list
print(f"\na is b : {a is b}")
del a # removes the name 'a' — but the object still exists via 'b'
print(f"b after del a : {b}")
value : 42
type : <class 'int'>
id (address): 140712345678912
y = x
x id : 140712345678912
y id : 140712345678912
same object? True
After x = 100:
x = 100 (now points to 100)
y = 42 (still points to 42)
same object? False
a is b : True
b after del a : [1, 2, 3]
CPython (the standard Python interpreter) pre-creates integer objects for the range -5 through 256 and reuses them. This means a = 42; b = 42; a is b returns True — both names point to the cached object. Outside that range, separate assignment statements create separate objects. This is an implementation detail, not guaranteed behavior — never rely on is for integer equality checks.
Assignment as Binding
The = operator in Python does not copy values. It binds a name to an object. Understanding every form of assignment as a binding operation — not a copy — is the foundation of avoiding dozens of common bugs.
# Every form of assignment is a binding operation
# 1. Simple assignment
score = 88
# 2. Chained assignment — ALL three names point at the same object
a = b = c = []
print(f"a is b is c : {a is b is c}")
a.append(1)
print(f"After a.append(1) — b={b} c={c}") # all three changed!
# To get independent empty lists, assign separately
x, y, z = [], [], []
x.append(1)
print(f"\nAfter x.append(1) — y={y} z={z}") # y and z unchanged
# 3. Assignment via for loop — each iteration binds the loop variable
items = ["alpha", "beta", "gamma"]
for item in items:
pass # after the loop, 'item' still exists and holds the last value
print(f"\nloop variable after loop: item = {item!r}")
# 4. Assignment via with statement — binds the context manager result
import io
with io.StringIO("hello") as f:
content = f.read()
print(f"with binding: content = {content!r}")
# 5. Function parameters are bindings too
def show_id(obj):
print(f" inside function: id={id(obj)}")
my_list = [1, 2, 3]
print(f"outside function: id={id(my_list)}")
show_id(my_list) # same id — the parameter IS the same object
a is b is c : True
After a.append(1) — b=[1] c=[1]
After x.append(1) — y=[] z=[]
loop variable after loop: item = 'gamma'
with binding: content = 'hello'
outside function: id=140712398765432
inside function: id=140712398765432
a = b = c = [] creates one list and makes all three names point to it. This is rarely what you want. Any mutation through any of the three names changes the shared object. Use separate assignments (a, b, c = [], [], []) or a comprehension ([[] for _ in range(3)]) to get independent objects.
Rebinding vs Mutation
This is the distinction that explains most "why did my variable change?" and "why didn't my variable change?" questions. Rebinding makes a name point at a different object. Mutation changes the object a name currently points at. For immutable types you can only rebind. For mutable types you can do both, and that is where the confusion arises.
# REBINDING — name moves to a new object, other names unaffected
x = 10
y = x
print(f"Before: x={x} y={y} same obj: {x is y}")
x = 20 # x is now bound to a new int object
print(f"After rebind x=20: x={x} y={y} same obj: {x is y}")
# MUTATION — object changes in place, all names pointing at it see the change
a = [1, 2, 3]
b = a
print(f"\nBefore mutation: a={a} b={b} same obj: {a is b}")
a.append(4) # mutates the object both a and b point to
print(f"After a.append(4): a={a} b={b} same obj: {a is b}")
# The critical question: does this function rebind or mutate?
def rebind_attempt(lst):
lst = [99, 98, 97] # this rebinds the LOCAL name 'lst' — caller unaffected
def mutate_attempt(lst):
lst.append(99) # this mutates the OBJECT — caller sees the change
original = [1, 2, 3]
rebind_attempt(original)
print(f"\nAfter rebind_attempt : {original}") # unchanged
mutate_attempt(original)
print(f"After mutate_attempt : {original}") # changed!
# String reassignment is always a rebind — strings are immutable
s = "hello"
t = s
s = s.upper() # creates a NEW string, binds s to it
print(f"\ns={s!r} t={t!r} same obj: {s is t}") # t still points to "hello"
Before: x=10 y=10 same obj: True
After rebind x=20: x=20 y=10 same obj: False
Before mutation: a=[1, 2, 3] b=[1, 2, 3] same obj: True
After a.append(4): a=[1, 2, 3, 4] b=[1, 2, 3, 4] same obj: True
After rebind_attempt : [1, 2, 3]
After mutate_attempt : [1, 2, 3, 99]
s='HELLO' t='hello' same obj: False
Multiple Assignment and Unpacking
Python supports assigning to multiple names in a single statement via tuple unpacking. This is not just syntactic sugar — it evaluates the right side completely before doing any binding, which is what makes simultaneous variable swaps work without a temporary variable.
# Tuple unpacking — right side fully evaluated first
a, b = 10, 20
print(f"a={a} b={b}")
# Swap without a temp variable — the RHS (b, a) is evaluated as a tuple
# BEFORE either name is rebound
a, b = b, a
print(f"After swap: a={a} b={b}")
# Unpacking a list or any iterable
first, second, third = [100, 200, 300]
print(f"\nfirst={first} second={second} third={third}")
# Extended unpacking with * — catch the "rest"
head, *tail = [1, 2, 3, 4, 5]
print(f"\nhead={head} tail={tail}")
*body, last = [1, 2, 3, 4, 5]
print(f"body={body} last={last}")
first, *middle, last = [1, 2, 3, 4, 5]
print(f"first={first} middle={middle} last={last}")
# Nested unpacking
data = [("Alice", 91), ("Bob", 74), ("Carol", 88)]
for name, score in data:
grade = "pass" if score >= 70 else "fail"
print(f" {name:<8} {score} {grade}")
# Underscore as a throwaway name — convention for "I don't need this"
_, important, _ = (10, 42, 99)
print(f"\nImportant value: {important}")
x, y, _ = (3.0, -1.5, 0.0) # discard z coordinate
print(f"2D point: ({x}, {y})")
a=10 b=20
After swap: a=20 b=10
first=100 second=200 third=300
head=1 tail=[2, 3, 4, 5]
body=[1, 2, 3, 4] last=5
first=1 middle=[2, 3, 4] last=5
Alice 91 pass
Bob 74 pass
Carol 88 pass
Important value: 42
2D point: (3.0, -1.5)
Augmented Assignment and What It Does to Objects
Augmented assignment operators like += look like they combine an operation and an assignment. But what they actually do depends on whether the object is mutable or immutable — and this creates one of the most counterintuitive behaviors beginners encounter.
# Augmented assignment on IMMUTABLE types — always a rebind
x = 10
print(f"Before: x={x} id={id(x)}")
x += 5 # creates a NEW int (15), rebinds x to it
print(f"After x+=5: x={x} id={id(x)} (different object)")
s = "hello"
print(f"\nBefore: s={s!r} id={id(s)}")
s += " world" # creates a NEW string, rebinds s
print(f"After s+=' world': s={s!r} id={id(s)} (different object)")
# Augmented assignment on MUTABLE types — mutates in place
lst = [1, 2, 3]
other = lst # other points at the same list
print(f"\nBefore: lst={lst} id={id(lst)}")
lst += [4, 5] # calls lst.__iadd__([4,5]) — mutates IN PLACE
print(f"After lst+=[4,5]: lst={lst} id={id(lst)} (same object!)")
print(f"other after lst+=: {other}") # other sees the change too!
# Contrast with + which always creates a new object
lst2 = [1, 2, 3]
other2 = lst2
lst2 = lst2 + [4, 5] # creates a NEW list, rebinds lst2
print(f"\nlst2={lst2} other2={other2} (other2 unchanged — lst2 was rebound)")
# The tuple-with-list mutation trap
t = ([1, 2], [3, 4])
print(f"\nt before: {t}")
t[0] += [99] # raises TypeError — can't assign to tuple item
# BUT the mutation still happened!
print(f"t after : {t}") # [1, 2, 99] despite the error
Before: x=10 id=140712300000001
After x+=5: x=15 id=140712300000002 (different object)
Before: s='hello' id=140712398112345
After s+=' world': s='hello world' id=140712398999876 (different object)
Before: lst=[1, 2, 3] id=140712399000001
After lst+=[4,5]: lst=[1, 2, 3, 4, 5] id=140712399000001 (same object!)
other after lst+=: [1, 2, 3, 4, 5]
lst2=[1, 2, 3, 4, 5] other2=[1, 2, 3] (other2 unchanged — lst2 was rebound)
t before: ([1, 2], [3, 4])
t after : ([1, 2, 99], [3, 4])
Tuples are immutable — you cannot assign to their elements. But if a tuple contains a mutable object like a list, you can still mutate that inner object. Using += on a list inside a tuple is particularly confusing: the mutation succeeds (the list grows), but Python then tries to rebind the tuple element and raises a TypeError. The result is a changed list inside an unchanged tuple structure — and an exception. Avoid += on mutable items inside tuples.
Naming Rules and Conventions
Python has hard rules about what constitutes a valid name, and conventions that experienced Python programmers follow by default.
| Pattern | Meaning | Example |
|---|---|---|
lowercase | Regular variable or function | score, user_name |
UPPER_CASE | Module-level constant (convention only — Python does not enforce it) | MAX_RETRIES, TAX_RATE |
_leading | Internal / not for public use | _cache, _helper() |
__dunder__ | Python special method or attribute | __init__, __name__ |
__leading_double | Name-mangled class attribute | __private |
_ | Throwaway / "I don't need this value" | for _ in range(3): |
# Valid names — letters, digits, underscore; cannot start with a digit
valid_name = 1
_also_valid = 2
name123 = 3
__dunder__ = 4 # valid but reserved for Python internals
# Invalid names — these raise SyntaxError
# 3invalid = 5 # starts with digit
# my-var = 6 # hyphens not allowed
# for = 7 # reserved keyword
# Python's reserved keywords — cannot be used as variable names
import keyword
print(f"Reserved keywords ({len(keyword.kwlist)}):")
print(", ".join(keyword.kwlist))
# Shadowing a built-in — legal but dangerous
list_ = [1, 2, 3] # trailing underscore avoids shadowing 'list'
# BAD — shadows the built-in list() function in this scope
# list = [1, 2, 3]
# list([1,2,3]) # now TypeError: 'list' object is not callable
# Checking if a string is a valid identifier
candidates = ["hello", "3d_model", "max_value", "class", "user-id"]
for c in candidates:
print(f" {c!r:<15} valid: {c.isidentifier()} keyword: {keyword.iskeyword(c)}")
Reserved keywords (35):
False, None, True, and, as, assert, async, await, break, class, continue, def, del, elif, else, except, finally, for, from, global, if, import, in, is, lambda, nonlocal, not, or, pass, raise, return, try, while, with, yield
'hello' valid: True keyword: False
'3d_model' valid: False keyword: False
'max_value' valid: True keyword: False
'class' valid: True keyword: True
'user-id' valid: False keyword: False
Scope: The LEGB Rule
Scope determines which names are visible at a given point in the code. When Python looks up a name, it searches four namespaces in order: Local → Enclosing → Global → Built-in. The first match wins.
# LEGB scope demonstration
x = "global" # G — module (global) scope
def outer():
x = "enclosing" # E — enclosing scope (for inner)
def inner():
x = "local" # L — local scope
print(f"inner sees x = {x!r}") # Local wins
def inner_no_local():
print(f"no-local sees x = {x!r}") # Enclosing wins
inner()
inner_no_local()
print(f"outer sees x = {x!r}") # Enclosing (its own local) wins
outer()
print(f"module sees x = {x!r}") # Global — outer() didn't change it
# B — Built-in scope: len, print, range, etc.
# Python searches built-ins last; shadowing a built-in is legal but unwise
print(f"\nlen is from built-in scope: {len([1,2,3])}")
# vars() and dir() let you inspect namespaces directly
def show_locals():
alpha = 1
beta = 2
gamma = 3
print(f"\nLocals inside show_locals(): {list(vars().keys())}")
show_locals()
# globals() returns the module-level namespace
print(f"Some globals: {[k for k in globals() if not k.startswith('_')][:6]}")
inner sees x = 'local'
no-local sees x = 'enclosing'
outer sees x = 'enclosing'
module sees x = 'global'
len is from built-in scope: 3
Locals inside show_locals(): ['alpha', 'beta', 'gamma']
Some globals: ['x', 'outer', 'show_locals']
Variables Inside Functions
Every function has its own local namespace. Names created inside a function exist only for the duration of that function call. If a function needs to read a global variable, Python finds it via LEGB without any declaration. If a function needs to rebind a global variable, it must declare it with global. Nested functions that rebind an enclosing variable use nonlocal.
count = 0 # global
def increment_bad():
# This READS count fine...
# But assigning to count here creates a LOCAL name, shadowing the global
# Uncommenting the next line would raise UnboundLocalError:
# count += 1 # Python sees the assignment and treats count as local
pass
def increment_good():
global count # declare: 'count' here refers to the global
count += 1
print(f"count before: {count}")
increment_good()
increment_good()
print(f"count after: {count}")
# nonlocal — rebind a name in the nearest enclosing (non-global) scope
def make_counter():
value = 0
def step():
nonlocal value # refers to 'value' in make_counter's scope
value += 1
return value
return step
counter = make_counter()
print(f"\ncounter() = {counter()}")
print(f"counter() = {counter()}")
print(f"counter() = {counter()}")
# UnboundLocalError — the classic scope surprise
total = 100
def broken():
# Because there is an assignment to 'total' later in this function,
# Python marks 'total' as local for the ENTIRE function body.
# Reading it before the assignment therefore raises UnboundLocalError.
try:
print(total) # ERROR — Python already decided 'total' is local
except UnboundLocalError as e:
print(f"UnboundLocalError: {e}")
total = 200 # this assignment is what caused the problem
broken()
print(f"global total unchanged: {total}")
count before: 0
count after: 2
counter() = 1
counter() = 2
counter() = 3
UnboundLocalError: local variable 'total' referenced before assignment
global total unchanged: 100
Deleting Names with del
The del statement removes a name from a namespace. It does not delete the object the name pointed to — it simply detaches the name. If no other names point to the object, Python's garbage collector will eventually reclaim the memory. If other names still point to it, the object lives on.
# del removes a NAME, not necessarily the object
x = [1, 2, 3]
y = x # y points at the same object
del x # removes the name 'x'
try:
print(x) # NameError — 'x' no longer exists
except NameError as e:
print(f"NameError: {e}")
print(f"y still exists: {y}") # object is alive via 'y'
# del on a list element or slice
nums = [10, 20, 30, 40, 50]
del nums[1] # removes element at index 1
print(f"\nAfter del nums[1] : {nums}")
del nums[1:3] # removes elements at indices 1 and 2
print(f"After del nums[1:3]: {nums}")
# del on a dict key
config = {"host": "localhost", "port": 8080, "debug": True}
del config["debug"]
print(f"\nAfter del config['debug']: {config}")
# Checking existence before access
data = {"name": "Alice"}
key = "age"
if key in data:
print(data[key])
else:
print(f"Key {key!r} not present — use .get() for safe access")
print(f"Safe access: {data.get('age', 'unknown')}")
NameError: name 'x' is not defined
y still exists: [1, 2, 3]
After del nums[1] : [10, 30, 40, 50]
After del nums[1:3]: [10, 50]
After del config['debug']: {'host': 'localhost', 'port': 8080}
Key 'age' not present — use .get() for safe access
Safe access: unknown
Key Takeaways
- A variable is a name, not a box: It points to an object. The object exists independently. Multiple names can point to the same object simultaneously.
- Assignment is binding, not copying: Every form of
=attaches a name to an object. To get an independent copy of a mutable object, you must copy it explicitly. - Rebinding and mutation are different operations: Rebinding moves the name to a new object and leaves other names unaffected. Mutation changes the object in place and is visible through every name that points to it.
- Chained assignment with mutable objects is a trap:
a = b = c = []creates one list shared by all three names. Usea, b, c = [], [], []for independent objects. - Augmented assignment behaves differently on mutable vs immutable types: On immutable types it always rebinds. On mutable types it mutates in place — other names pointing to the same object will see the change.
- Python uses LEGB scope lookup: Local → Enclosing → Global → Built-in. A function that assigns to a name automatically treats that name as local for the entire function body, even before the assignment line is reached.
delremoves a name, not an object: The object persists as long as any other name points to it.delon a list element or dict key removes that element from the container.
Every quirk in how Python variables behave traces back to the same root: names and objects are separate things. Once that mental model is in place, the behaviors that look like bugs become predictable, and the patterns that protect against them become obvious.