Change a number and nothing else moves. Change a list, and suddenly another variable elsewhere in your program reports the same edit — even though you never touched it. That is not a bug in Python; it is the single most important idea in how the language handles data, and it catches every newcomer once.
The difference comes down to one word: mutability. Some objects can be altered in place after they are created; others cannot. Knowing which is which — and what happens when you hand one to a function — is what separates code that quietly corrupts its own data from code you can actually trust.
This lesson makes the model explicit. Once you can picture where an object lives and who else holds a reference to it, the confusing behaviours stop being confusing.
flowchart LR A["a = [1, 2, 3]"] --> O["shared list object"] B["b = a"] --> O O --> M["b.append(4) changes it<br/>for a as well"] style O fill:#3776ab,color:#fff style M fill:#b45309,color:#fff
Mutable versus immutable
In Python every value is an object, and every name is a label stuck onto one. Integers, floats, strings, and tuples are immutable — once built, their value cannot change. Any operation that looks like it changes one actually constructs a fresh object and re-points the name at it.
Lists, dictionaries, and sets are mutable. You can append, pop, or reassign a key, and the same object now holds different contents.
You can watch the difference with id(), which reports an object's identity — roughly, where it sits in memory. Rebinding a name to a new value usually changes its id; mutating a container does not.
flowchart LR
subgraph I["immutable"]
I1["int float str<br/>tuple bool frozenset"]
end
subgraph M["mutable"]
M1["list dict set"]
end
style I1 fill:#3776ab,color:#fff
style M1 fill:#b45309,color:#fff
n = 10
print("n before:", id(n))
n = n + 5 # builds a NEW integer object
print("n after: ", id(n))
letters = ["a"]
print("letters before:", id(letters))
letters.append("b") # mutates the SAME list object
print("letters after: ", id(letters))
Rebinding versus mutating
Watch the assignment operator closely, because = does two different jobs. Writing n = n + 5 rebinds the name n to a brand-new integer; the old object is untouched. Writing nums.append(5) mutates the list nums already labels; no new object appears, and every other name on that object sees the change at once.
The augmented operator += makes this treacherous. On a list, nums += [4] mutates in place. On an integer, n += 1 rebinds. Same symbol, opposite effect — because integers are immutable and lists are not. When one line refuses to behave like its neighbour, mutability is usually why.
nums = [1, 2, 3]
alias = nums
nums += [4] # mutates the SAME list object
print("alias sees it too:", alias)
total = 0
total += 10 # rebinds total to a new integer
print("total:", total)
Pass-by-object-reference
When you call a function and hand it an argument, Python does not copy the value. It passes the reference — the function's parameter becomes another label on the very same object.
For an immutable argument that is harmless: the function cannot alter it, so the caller is safe. For a mutable argument it is powerful and dangerous at once, because a function that appends to the list it received is editing the caller's list in place, whether the caller wanted that or not.
This is why so many list methods — append, sort, reverse — return None. They change the object itself rather than producing a new one. Mixing up a mutating method with one that returns a fresh value is a frequent source of None errors.
flowchart TD C["caller<br/>cart = ['milk']"] -->|"add_item(cart)"| F["parameter basket"] F -.->|"same object"| O["['milk', 'egg']"] C -.->|"same object"| O style F fill:#3776ab,color:#fff style O fill:#b45309,color:#fff
def add_item(basket):
basket.append("egg")
cart = ["milk"]
add_item(cart)
print("cart is now:", cart)
== versus is
Two questions that look alike answer different things. == asks do these objects hold the same value? is asks are they literally the same object in memory?
[1, 2] == [1, 2] is True — the values match. [1, 2] is [1, 2] is False — they are two separate list objects that merely contain the same things. is checks identity; == checks equality, and for lists it compares element by element.
Reach for == almost always. Reach for is in two cases: comparing to the singleton None (if x is None), and when you genuinely care about identity rather than value.
Write without_last(items) that returns a new list containing every item except the last. It must NOT mutate the original list you are given — copy first, then drop the final element.
def without_last(items):
# return a copy with the last item dropped; leave `items` untouched
pass
Make a copy first:
copy = list(items).Then
copy.pop()removes and returns the last item.
def without_last(items):
copy = list(items)
copy.pop()
return copy
Aliasing check. b = a does not copy the list. Predict both lines before you run it.
a = [1, 2] b = a b.append(3) print(a) print(a is b)
bandaare two names for one list, so appending throughbchanges whatasees.isis True only when two names point at the very same object.
This function should return a new list of words each with suffix added — but it mutates the caller's list as a side effect. Fix it so the caller's original words stay unchanged.
def add_suffix(words, suffix):
for i in range(len(words)):
words[i] = words[i] + suffix
return words
Build a copy to work on:
result = list(words).Loop over the copy's indices, then return it — never touch
words.
def add_suffix(words, suffix):
result = list(words)
for i in range(len(result)):
result[i] = result[i] + suffix
return result
Given two separate lists with equal contents, what do [1, 2] == [1, 2] and [1, 2] is [1, 2] return?
Two distinct list objects with the same contents compare equal by value, so == is True. They live at different addresses, so is (identity) is False.
Write add_to_each(matrix, n) that returns a NEW matrix (a list of lists) in which every number has had n added to it. The caller's original matrix — including its INNER lists — must stay unchanged. A shallow matrix.copy() is not enough, because the inner lists are shared; you must rebuild them.
def add_to_each(matrix, n):
# return a new matrix; do not mutate the original OR its inner lists
pass
A comprehension builds fresh inner lists:
[[v + n for v in row] for row in matrix].Because every inner list is new, the caller's nested lists cannot be affected.
def add_to_each(matrix, n):
return [[value + n for value in row] for row in matrix]
Recap
- Immutable types (
int,float,str,tuple) cannot change; operations that appear to change them create new objects. Mutable types (list,dict,set) change in place. - Assignment and function calls pass a reference to the same object — never an automatic copy. Two names can label one list.
==compares value;iscompares identity. Use==for values, andisonly forNoneand genuine identity checks.- A function that mutates its argument edits the caller's data. Copy first —
list(x),x[:], orcopy.deepcopyfor nested data — when that is not the intent.
Next you will add type hints, the annotations that tell readers and tooling what type each value should be — a layer professional codebases lean on to catch exactly these reference bugs before the code ever runs.