Variables & Names
In Python, a variable is a name bound to an object, not a labeled box holding a value — this topic covers assignment, rebinding, unpacking, augmented assignment, and how names resolve through local, enclosing, global, and built-in scopes.
The single most important mental model shift for Python variables, especially coming from C-family languages, is this: a Python "variable" is a name bound to an object living somewhere in memory, not a fixed-size storage box that holds a value directly. Assignment binds a name to an object; it does not copy that object. This topic covers how names are created, rebound, unpacked, and resolved across nested scopes.
Names Are Bindings, Not Boxes
Assignment in Python creates a binding — an association between a name and an object — inside some namespace. It is fundamentally different from C's model, where a variable is a memory location of a fixed size that a value is copied into. In Python, the object exists independently in memory, and a name is just a reference pointing at it. Multiple names can point at the very same object at once.
This aliasing behavior — and the mutable/immutable distinction that determines when it matters — is explored fully in a dedicated Mutability & Aliasing topic elsewhere in this course; here it's introduced only as the foundation for how assignment works.
a = [1, 2, 3]
b = a # b is bound to the SAME list object as a — not a copy
b.append(4)
print(a) # [1, 2, 3, 4] — the mutation through b is visible through a too, since they're the same objectAssignment
The = operator performs assignment: it evaluates the right-hand side to get an object, then binds the left-hand name to that object. Assignment is a statement, not an expression (see the Program Structure topic) — it produces no usable value itself.
score = 100
name = "Player One"
is_active = TrueRebinding
Because names are just bindings, assigning a new value to an already-used name doesn't modify the old object — it simply points that name at a different object, leaving the previous object unchanged (and, if nothing else refers to it, eligible for garbage collection). This is what makes Python's dynamic typing work: a name can be rebound to an object of an entirely different type.
x = 5 # x bound to an int object
x = x + 1 # a NEW int object (6) is created; x is rebound to it — the int 5 is untouched
x = "six" # x rebound again, now to a str objectMultiple Assignment
Multiple assignment (also called chained assignment when written as a=b=c=value) lets one statement bind several names at once. Two distinct forms exist and are easy to conflate: binding several names to the same value, versus binding several names to different values in one line via unpacking (covered next).
x = y = z = 0 # all three names bound to the SAME int object 0
print(x, y, z) # 0 0 0
x = 1 # rebinding x does NOT affect y or z — ints are immutable, and rebinding only moves x's own reference
print(x, y, z) # 1 0 0Chained Assignment — A Caution With Mutable Objects
Chained assignment (a = b = c = value) is safe and common with immutable values, but worth pausing on when value is mutable, because all names end up bound to the exact same object — mutating through any one of them is visible through all of them.
a = b = [] # a and b are the SAME list object
a.append(1)
print(b) # [1] — mutation through a is visible via b, since they're identical objectsUnpacking
Unpacking assigns multiple names at once from a single iterable, matching names to elements positionally. The number of names must match the number of elements unless you use a starred name to collect the "rest" into a list.
The swap idiom a, b = b, a works because the entire right-hand side is evaluated into a tuple first, before any assignment happens — so both original values are captured before either name is rebound.
x, y = 1, 2
print(x, y) # 1 2
first, *middle, last = [1, 2, 3, 4, 5]
print(first, middle, last) # 1 [2, 3, 4] 5
a, b = b, a # the classic Python swap idiom — no temp variable neededAugmented Assignment
Augmented assignment operators (+=, -=, *=, /=, //=, %=, **=, &=, |=, ^=, <<=, >>=) combine an operation with assignment in one step. For immutable types, x += 1 behaves exactly like x = x + 1 — it computes a new object and rebinds x. For mutable types like list, += can (and typically does) mutate the object in place rather than creating a new one, which is a subtle but important distinction.
+= on a list mutates in place via the list's __iadd__ method, whereas lst = lst + [3] always creates a brand-new list object. Both usually look identical from the printed result, but they behave differently if another name is aliased to the same list — this distinction is explored fully in the Mutability & Aliasing topic.
x = 5
x += 1 # equivalent to x = x + 1; x now bound to a new int object 6
lst = [1, 2]
lst += [3] # mutates the SAME list object in place (calls __iadd__)
print(lst) # [1, 2, 3]Namespace Concept
A namespace is a mapping from names to objects — in practice, Python implements each namespace roughly like a dictionary. Different namespaces exist simultaneously and independently: the local namespace of a currently-executing function, the global namespace of a module, and the built-in namespace containing names like print and len. The same name can exist in multiple namespaces at once, referring to entirely different objects, without conflict.
x = "global x"
def show():
x = "local x" # a different namespace — does not affect the global x
print(x)
show() # local x
print(x) # global x — unaffectedLocal Scope
Names assigned anywhere inside a function body are, by default, local to that function — they live in a fresh local namespace created each time the function is called, and they disappear when the function returns. This is true even if the assignment happens after a read of a same-named global in the source — Python determines a name is local to a function based on the presence of any assignment to that name anywhere in the function body, decided at compile time.
def f():
print(x) # UnboundLocalError, NOT the global x —
x = 1 # because x is assigned somewhere in this function,
# Python treats x as local for the ENTIRE function bodyGlobal Scope and the global Keyword
Names assigned at module level (not inside any function or class) live in that module's global namespace. Reading a global name from inside a function works automatically (Python looks outward when a name isn't found locally), but assigning to a name inside a function makes it local by default, even if a global of the same name exists — unless you explicitly declare it global first.
counter = 0
def increment():
global counter
counter += 1 # without "global counter" above, this would raise
# UnboundLocalError, since assignment makes counter local by default
increment()
print(counter) # 1Nonlocal Scope and the nonlocal Keyword
nonlocal is used inside a nested function to assign to a name that belongs to an enclosing (but not global) function's scope — the scope one or more levels up, but still inside a function, not at module level. It's the mechanism that lets closures maintain mutable state across calls.
Closures and their interaction with nonlocal are covered more deeply in the dedicated Closures topic; this section introduces nonlocal purely as a scope-resolution keyword.
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2Complete Example
def summarize_scores(*scores: int) -> tuple[int, float, int]:
"""Return (highest, average, lowest) for the given scores."""
total = 0
highest = scores[0]
lowest = scores[0]
for score in scores:
total += score
if score > highest:
highest = score
if score < lowest:
lowest = score
average = total / len(scores)
return highest, average, lowest
highest, average, lowest = summarize_scores(88, 92, 79, 95, 84)
print(f"High: {highest}, Average: {average:.1f}, Low: {lowest}")
# High: 95, Average: 87.6, Low: 79Key Takeaways
A Python variable is a name bound to an object; assignment binds, it does not copy — multiple names can reference the same object.
Rebinding a name points it at a new object; it never mutates whatever object it previously referred to.
Chained assignment (a = b = value) binds every name to the exact same object — a real hazard when value is mutable.
Unpacking assigns from an iterable positionally, in one statement; a starred name collects the remainder into a list.
Augmented assignment can mutate in place for mutable types (list += ) but always rebinds for immutable types (int += ).
Assigning to a name anywhere inside a function body makes that name local for the whole function, by default; global and nonlocal explicitly opt out of that default.
def f(): print(x) # UnboundLocalError x = 1 def f(): x = 1 print(x) # or use "global x" if you really mean the module-level x
Any assignment to a name anywhere in a function body makes that name local for the ENTIRE function, even on lines before the assignment — so reading it first raises UnboundLocalError rather than falling back to a same-named global.
a = b = [] a.append(1) # surprised that b also changed a = [] b = [] # two separate list objects, if independent lists are what's intended a.append(1)
Chained assignment binds every name to the identical object, not to independent copies — mutating through one name is visible through all of them when the object is mutable.
def increment(counter): counter += 1 # rebinds the local parameter; caller's variable is unaffected n = 0 increment(n) print(n) # still 0 def increment(n: int) -> int: return n + 1 n = increment(n) # explicitly rebind the caller's variable using the return value
Rebinding a parameter inside a function (via = or += on an immutable type) never affects the caller's variable — only mutating a mutable object in place is visible outside the function. Ints are immutable, so += on an int parameter just rebinds the local name.
Why is it more accurate to say Python assignment 'binds a name to an object' rather than 'stores a value in a variable'?
Because Python names don't hold values directly the way C-style variables do — a name is a reference to an object that exists independently in memory. Assignment creates or updates that binding; it doesn't copy the object's data into the name's own storage. This is why a = b makes two names for the same object rather than two independent copies.
What happens, step by step, when you run a = b = [] and then a.append(1)? What does b look like afterward?
a = b = [] creates one empty list object and binds both names a and b to that same object. a.append(1) mutates that shared object in place. Since b refers to the identical object, printing b afterward shows [1] as well — the mutation is visible through both names.
Given def f(): x = x + 1, called with no prior definition of a global x, what error occurs and why?
UnboundLocalError. Because x is assigned somewhere in f's body, Python treats x as local to f for the function's entire execution — so the read of x on the right-hand side (before it has been given a value in this call) fails, rather than falling back to any global x.
What is the difference in effect between global counter and nonlocal counter inside a nested function?
global counter tells Python that assignments to counter inside this function should modify the name in the module's global namespace. nonlocal counter tells Python that assignments should modify counter in the nearest enclosing function scope (not global, and not local) — it's used specifically for closures over an outer function's variables.
Explain what first, *rest = [10, 20, 30, 40] binds first and rest to.
first is bound to 10 (the first element). rest is bound to a new list [20, 30, 40] containing the remaining elements — the starred name always collects the leftover items into a list, regardless of how many there are.
Why does lst += [4] behave differently from lst = lst + [4] when another variable is aliased to the same list?
lst += [4] calls the list's __iadd__ method, which mutates the existing list object in place — any other name aliased to that same list object sees the change too. lst = lst + [4] instead creates a brand-new list object and rebinds only lst to it, leaving any other alias pointing at the original, now-unmodified list.
In a, b = b, a, why does this correctly swap the two values instead of, say, both ending up equal to the original b?
Because Python fully evaluates the right-hand side (b, a) into a tuple of the original values BEFORE performing any assignment. Both original values are captured first, then unpacked into a and b — so neither assignment can interfere with the other's source value.
What does this print?
It prints 15. nonlocal x inside g() binds assignments to the x in the enclosing function f()'s scope, so x += 5 mutates that outer variable rather than creating a separate local x inside g().
Write a function swap_first_last(items) that returns a new list with the first and last elements swapped, using unpacking (assume at least 2 elements).
def swap_first_last(items: list) -> list: new_items = items.copy() new_items[0], new_items[-1] = new_items[-1], new_items[0] return new_items
This copies the list first (to avoid mutating the caller's original list), then uses tuple-unpacking-style simultaneous assignment to swap the first and last positions in one statement.
What happens when increment() is called, and why?
It raises UnboundLocalError. Because count is assigned to inside increment (count += 1), Python treats count as a local variable for the whole function — so the read of count on the right side of += fails before it's ever been locally assigned, rather than reading the module-level count. Adding global count at the top of the function would fix this.
Write code that binds a, b, and c to the values 1, 2, and 3 respectively in a single line, using unpacking (not chained assignment).
a, b, c = 1, 2, 3 This unpacks the tuple (1, 2, 3) on the right-hand side positionally into a, b, and c — distinct from chained assignment (a = b = c = 1), which would bind all three names to the very same single value/object.
def f():
x = 10
def g():
nonlocal x
x += 5
g()
return x
print(f())count = 0
def increment():
count += 1
increment()