Why two variables can secretly be the same list — and why that catches people off guard.
A mutable thing can be changed after it's created, in place — lists and dicts work this way. An immutable thing can't — strings, numbers, booleans, and None (everything from Module 1) never change once made. "Changing" one of those actually makes a brand new one.
colors = ["red"]
colors.append("blue") # same list, changed in place
x = 5
x = x + 1 # NOT the same 5 changed -- a new 6, x just points to it now
Is a list mutable or immutable?
Is a string mutable or immutable?
Is a dict mutable or immutable?
Is an integer mutable or immutable?
colors = ["red"]. Append "blue", print colors — this is mutating, not reassigning.
['red', 'blue']Open problems/01_mutable_vs_immutable.py and do all 5 problems locally.
These look similar but are completely different: mutating changes the object itself (.append()). Reassigning just points the variable at a different object (x = x + [3] builds a new list).
a = [1, 2]
a.append(3) # mutate -- same list, now [1, 2, 3]
b = [1, 2]
b = b + [3] # reassign -- a brand new list, b just points to it now
a = [1, 2]. Append 3 (mutate), print a.
[1, 2, 3]b = [1, 2]. Reassign b = b + [3], print b.
[1, 2, 3] — same visible result, different mechanismx = "hi". Reassign x = x + "!", print x.
hi! — strings can only ever be reassigned, never mutatedd = {"a": 1}. Mutate it by adding a key, print d.
{'a': 1, 'b': 2} (whatever you added)Is d["a"] = 2 (changing a dict's value) a mutation or a reassignment of d itself?
d still points to the same dictcount = 0. Reassign count = count + 1 three times in a row, print the final value.
3Open problems/02_reassign_vs_mutate.py and do all 6 problems locally.
b = a does not copy the list — it makes b point at the exact same list as a. Change one, and both variables show the change, because there's only one actual list.
a = [1, 2]
b = a # b points at the same list as a, not a copy
b.append(3)
print(a) # [1, 2, 3] -- a changed too!
a = ["red"]. b = a. Append "blue" to b. Print a.
['red', 'blue'] — a changed even though you only touched bSame setup. Print b too — confirm it matches a.
['red', 'blue']a = [1, 2, 3]. b = a. Pop the last item off b. Print a.
[1, 2]a = [1]. b = a. Insert 0 at position 0 in a. Print b — the change shows up on the other name too.
[0, 1]a = [1, 2]. b = a. b.append(3). What does print(len(a)) print?
3a and b are the same list — appending through b grew a too.
Open problems/03_alias_list.py and do all 6 problems locally.
Same rule as lists — b = a on a dict points b at the same dict, not a copy.
a = {"status": "ACTIVE"}
b = a
b["status"] = "SUSPENDED"
print(a) # {'status': 'SUSPENDED'} -- a changed too
a = {"status": "ACTIVE"}. b = a. Update b["status"] to "SUSPENDED". Print a.
{'status': 'SUSPENDED'}Same setup. Print a is b — confirms they're the exact same dict.
Truea = {"x": 1}. b = a. Add a new key to b. Print a.
a = {"x": 1, "y": 2}. b = a. Pop "y" from b. Print a.
{'x': 1}a = {}. b = a. Add a key to a this time (not b). Print b — same result either direction.
Predict then check: a = {"a": 1}. b = a. c = b. Update c["a"] to 99. Print a — three names, still one dict.
{'a': 99}Open problems/04_alias_dict.py and do all 6 problems locally.
Two separately created lists can hold identical values (== says True) while still being two different objects in memory (is says False). is only says True when it's genuinely the same object — which is exactly what aliasing (b = a) creates.
a = [1, 2]
b = [1, 2] # a separate list that happens to look the same
print(a == b) # True -- same values
print(a is b) # False -- different objects
c = a
print(a is c) # True -- c is literally a, from aliasing
a = [1, 2], b = [1, 2] (built separately). Print a == b.
TrueSame two lists. Print a is b.
Falsea = [1, 2]. c = a (aliased). Print a is c.
Truex = {"a": 1}, y = {"a": 1} (built separately). Print x == y, then x is y — two lines.
True then FalseWhich one — == or is — would tell you whether mutating one list will affect another? Print a demonstration proving your answer.
isRecall Module 1: x = None. Print x is None — the same is, a different context.
TrueOne script: create a list a, alias it as b, mutate through b, print a to show the effect, then print a is b to explain why.
Open problems/05_is_vs_eq.py — 7 problems, #7 is a checkpoint combining sub-skills 1–5.
.copy() makes an actual second list — a new object with the same values, not just another name for the same one. Now mutating one doesn't affect the other.
a = [1, 2]
b = a.copy()
b.append(3)
print(a) # [1, 2] -- unaffected this time
a = ["red"]. b = a.copy(). Append "blue" to b. Print a.
['red'] — unaffectedSame setup. Print b — it has the change, a doesn't.
['red', 'blue']Print a is b after copying — confirms they're different objects now.
FalsePrint a == b right after copying, before any mutation — still equal in value.
TrueAnother way to copy: b = a[:] (slicing the whole thing). Try it, mutate b, print a unaffected.
Copy a list, remove an item from the copy, print both — only the copy shrank.
Open problems/06_copy_list.py and do all 6 problems locally.
Same idea — .copy() on a dict makes a real second dict.
a = {"status": "ACTIVE"}
b = a.copy()
b["status"] = "SUSPENDED"
print(a) # {'status': 'ACTIVE'} -- unaffected
a = {"status": "ACTIVE"}. b = a.copy(). Update b["status"]. Print a.
{'status': 'ACTIVE'} — unaffectedSame setup. Print a is b.
FalsePrint a == b right after copying, before mutating.
TrueCopy a dict, add a new key to the copy only, print both to confirm only the copy changed.
Copy a dict, pop a key from the original, print the copy to confirm it still has that key.
Copy, then check len() of both — same right after copying.
Open problems/07_copy_dict.py and do all 6 problems locally.
Here's the trap: .copy() only copies the outer object. If a value inside is itself a list or dict, both copies still point at that same inner object.
a = {"profile": {"email": "test@example.com"}}
b = a.copy()
b["profile"]["email"] = "changed@example.com"
print(a) # {'profile': {'email': 'changed@example.com'}} -- a changed too!
a = {"profile": {"dept": "IT"}}. b = a.copy(). b["profile"]["dept"] = "Sales". What does print(a["profile"]["dept"]) show?
Sales — a changed tooThe outer dict got copied, but the inner profile dict is the same one both copies point at. .copy() only goes one level deep — this is why it's called a "shallow" copy.
Confirm it yourself: build the example above, print a["profile"] is b["profile"].
True — same inner dictPrint a is b in the same setup — the outer dicts, at least, are different.
FalseSame trap with a list inside a dict: a = {"groups": ["IT"]}. Copy it, append to b["groups"], print a["groups"].
Print a["groups"] is b["groups"] for the setup above.
TrueContrast: a dict with only flat (non-list, non-dict) values — copy it, mutate the copy's flat value, print the original. This one is safe.
Open problems/08_shallow_copy.py and do all 7 problems locally.
Strings and numbers never have any of these problems, because they can never be mutated — only reassigned. Aliasing one just means both names point at the same unchangeable value, and there's nothing to accidentally break.
a = "hello"
b = a
b = b + " world"
print(a) # hello -- completely unaffected, no aliasing risk existed
a = "hi". b = a. Reassign b = b + "!". Print a.
hi — unaffectedx = 5. y = x. Reassign y = y + 1. Print x.
5 — unaffectedPrint x is y for the setup above, after the reassignment.
False — y now points somewhere newProof strings really can't be mutated: s = "hi". This SHOULD crash: s[0] = "H". Write it and run it — watch the error.
TypeError: 'str' object does not support item assignmentAlias a number 3 times (a = 1, b = a, c = b), reassign only c, print all three.
1\n1\nnew valueSame experiment with a list instead: alias 3 times, mutate (not reassign) through the third name, print all three.
Print "hello" is "hello" — two identical string literals. Python will actually warn you about this line.
True, but with a SyntaxWarning: "is" with 'str' literal. Did you mean "=="?This is the real lesson: is is for checking None or confirming aliasing (same object), never for comparing values — Python itself tells you to use == instead. That it happens to say True here is an implementation detail, not something to rely on.
One script covering the whole module: alias a list and show the mutation bug, copy a list properly, alias a dict with a nested dict and hit the shallow-copy gotcha, and reassign a string to prove it's unaffected. Print a result after each step.
Open problems/09_immutables_safe.py — 8 problems, #8 is the final checkpoint for all of Module 4.
Run python check.py in problems/ to grade everything.