SECTION 1
Historical Context & Motivation
When programming languages were first being designed, engineers had to decide a fundamental question: when you change a piece of data, should the original value be modified directly, or should the language create a brand-new copy? This choice — whether data is mutable (changeable in place) or immutable (unchangeable once created) — has shaped how every modern language handles data structures.
Early languages like Fortran and C gave programmers raw access to memory, meaning almost everything was mutable by default. This was powerful but dangerous, because changing data in one part of a program could create hard-to-find bugs elsewhere. As languages evolved, designers began to separate mutable and immutable types intentionally to make code safer and easier to reason about.
1957 • Fortran & Raw Memory
1972 • C Introduces Pointers
1991 • Python Is Born
2000s • Functional Programming Revival
The central question this lesson addresses is: when you modify a Python list, what actually happens in memory, and why can that single change affect other parts of your code? Answering this question is essential for writing correct, predictable programs.
SECTION 2
Core Principles of Mutability
To understand list mutability, you need to grasp a few foundational ideas about how Python manages data behind the scenes. Every value in Python lives at a specific location in memory, and variables are simply references — names that point to those memory locations. When you assign a list to a variable, the variable stores a reference (like an address) to the list object, not a separate copy of the list itself.
Mutable Object
Immutable Object
Reference (Alias)
In-Place Modification
Identity vs. Equality
KEY TAKEAWAY
SECTION 3
Visualizing References and In-Place Changes
The diagram below illustrates the most important — and most commonly misunderstood — behavior of mutable lists. When you write b = a, Python does not copy the list. Instead, both a and b become references — arrows pointing to the exact same list object in memory. Modifying the list through either name changes it for both.
a references a list. In Step 2, b = a creates a second reference (alias) to the same object. In Step 3, modifying the list through b changes what a sees too, because both arrows point to the same list in memory.This is the core surprise for many new Python programmers. With immutable types like strings and integers, you never encounter this problem because any 'change' creates a new object. But with lists, the original object is modified directly — and every variable that references it will see the change. This is what makes understanding mutability so important.
SECTION 4
How Mutability Works Under the Hood
In Python, every object has three properties: a type (what kind of object it is), a value (its contents), and an identity (its unique address in memory, which you can check with the id() function). For mutable objects, the value can change while the identity stays the same. For immutable objects, the value can never change — any operation that seems to change it actually creates a new object with a new identity.
Mutable Operations vs. Reassignment
It is critical to distinguish between mutating a list and reassigning a variable. When you write a.append(40), you are reaching into the existing list object and adding an element — the identity stays the same, and all aliases see the change. When you write a = [1, 2, 3], you are pointing the variable a at a brand-new list object — the old list is unaffected, and any aliases still point to the original.
| Operation | Category | Changes id()? | Aliases Affected? |
|---|---|---|---|
a[0] = 99 | In-place mutation | No | Yes — all aliases see it |
a.append(40) | In-place mutation | No | Yes — all aliases see it |
a.sort() | In-place mutation | No | Yes — all aliases see it |
a = a + [40] | Reassignment (new list) | Yes | No — aliases keep old list |
a = sorted(a) | Reassignment (new list) | Yes | No — aliases keep old list |
Mutation vs. Reassignment: how each operation affects the list's identity and aliases
The id() Test
print(id(a)) before and after an operation. If the id stays the same, the list was mutated in place. If the id changes, a new object was created.SECTION 5
Shallow Copies vs. Deep Copies
If aliasing is the problem, the solution is copying. When you want two independent lists, you need to create a true copy so that changes to one do not affect the other. Python provides two levels of copying: shallow copy and deep copy. For most cases in this course, a shallow copy is all you need.
A shallow copy creates a new list object and copies references to each element. For a list of simple values like integers or strings, this is perfectly safe. However, if your list contains other lists (a nested list), the inner lists are still shared between the original and the copy. In that case, you would need copy.deepcopy() to recursively copy everything, but that scenario is less common in an introductory course.
b = a[:]— slice the entire list to produce a shallow copyb = list(a)— use the list constructor to build a new list fromab = a.copy()— call the built-in copy method (Python 3.3+)
SECTION 6
Worked Example: Tracking a Mutable List Through Code
Let's trace through a realistic snippet of Python code step by step, paying close attention to which variables share the same list object and which have independent copies.
Tracing Aliases and Copies
- 1
Step 1 — Create the original list
We begin withscores = [85, 90, 78]. Python creates a new list object in memory and assigns the namescoresto point at it.scores → [85, 90, 78] (let's say id = 1000) - 2
Step 2 — Create an alias
We writebackup = scores. This does NOT copy the list. The variablebackupnow points to the exact same object asscores. We can verify:scores is backupreturnsTrue.scores → [85, 90, 78] (id = 1000) | backup → same object (id = 1000) - 3
Step 3 — Create a true copy
We writesafe_copy = scores[:]. This creates a brand-new list object containing the same values. Nowscores is safe_copyreturnsFalse, butscores == safe_copyreturnsTrue.safe_copy → [85, 90, 78] (id = 2000, different object) - 4
Step 4 — Mutate the original list
We writescores[1] = 95. This is an in-place modification — the list at id 1000 now holds [85, 95, 78]. Sincebackupalso points to id 1000, it sees the change. Butsafe_copypoints to id 2000 and is unaffected.scores → [85, 95, 78] | backup → [85, 95, 78] | safe_copy → [85, 90, 78] - 5
Step 5 — Verify with print statements
print(scores)outputs[85, 95, 78].print(backup)outputs[85, 95, 78]— confirming the alias was affected.print(safe_copy)outputs[85, 90, 78]— confirming the copy was independent.Alias shares changes. Copy stays independent.
SECTION 7
Mutable vs. Immutable Types in Python
Python deliberately divides its built-in types into mutable and immutable categories. Understanding which types fall into each group helps you predict how your code will behave when you pass data to functions, store it in variables, or share it between different parts of a program.
| Type | Mutable? | Example | Key Behavior |
|---|---|---|---|
list | Yes | [1, 2, 3] | Can append, remove, sort, or change elements in place |
dict | Yes | {"a": 1} | Can add, remove, or update key-value pairs |
set | Yes | {1, 2, 3} | Can add or discard elements |
str | No | "hello" | Any 'change' creates a new string object |
tuple | No | (1, 2, 3) | Cannot change after creation — like a frozen list |
int / float | No | 42, 3.14 | Reassignment creates a new number object |
Common Python types organized by mutability
KEY TAKEAWAY
SECTION 8
Mutability and Functions: A Common Pitfall
One of the most common bugs in Python comes from passing a mutable list to a function. When you pass a list as an argument, the function's parameter becomes another alias to the same object. If the function modifies the list in place, the caller's list is changed too — even though the function looks like it's working with its own local variable. This is called a side effect.
| Scenario | What Happens | How to Prevent Surprise |
|---|---|---|
Function uses .append() on parameter | Caller's list grows — side effect | Pass a copy: func(my_list[:]) |
Function uses .sort() on parameter | Caller's list gets reordered — side effect | Use sorted() instead (returns new list) |
Function reassigns parameter with = | Caller's list is NOT changed — reassignment rebinds the local name | No issue — but function can't communicate result this way |
Default mutable argument: def f(lst=[]): | Default list is shared across calls — dangerous bug! | Use None as default, create list inside function |
How mutability interacts with function calls
As you move into more advanced programming — including object-oriented programming and data science with libraries like NumPy and Pandas — mutability becomes even more important. Large datasets are often stored in mutable structures for efficiency, so understanding when you're working with the original data versus a copy is critical to avoiding data corruption and bugs in real-world projects.
Looking Ahead
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
Show answer
my_list[0] = 99 modifies the existing list without creating a new object. An immutable object, such as a string, cannot be changed after creation. If you write my_string = my_string + "!", Python creates a brand-new string object and reassigns the variable to point to it. The key consequence is that multiple references to a mutable object all see changes, while immutable objects are 'safe' from aliasing surprises.Question 2: BASIC
x = [1, 2, 3]
y = x
y.append(4)
print(x)Show answer
[1, 2, 3, 4]. When we write y = x, we create an alias — both x and y point to the same list object in memory. The .append(4) method mutates the list in place (it does not create a new list). Since x and y reference the same object, printing x shows the appended value too.Question 3: INTERMEDIATE
a = [5, 10, 15]
b = a[:]
c = a
a[0] = 99
print(a)
print(b)
print(c)
print(a is c)
print(a is b)Show answer
[99, 10, 15]
[5, 10, 15]
[99, 10, 15]
True
False
Variable b was created using slice notation a[:], which produces a shallow copy — a separate list object. Variable c was assigned directly from a, making it an alias. When a[0] is changed to 99, c reflects the change (same object), but b is unaffected (different object). The is operator confirms identity: a is c is True, a is b is False.Question 4: APPLIED
def add_bonus(scores, bonus):
for i in range(len(scores)):
scores[i] += bonus
return scores
original = [70, 80, 90]
updated = add_bonus(original, 5)
print(original)
print(updated)
What does this print, and how could the teacher fix the function so that original is not accidentally modified?Show answer
[75, 85, 95] because the function modifies the list in place. When original is passed to the function, the parameter scores becomes an alias for the same list. To fix this, the teacher should work on a copy inside the function: change the first line to scores = scores[:] or scores = list(scores). This way, the function creates an independent list, modifies the copy, and returns it — leaving original unchanged.Question 5: CRITICAL THINKING
def mystery(data):
data = data + [100]
return data
nums = [1, 2, 3]
result = mystery(nums)
print(nums)
print(result)
Explain why this function does NOT modify nums, even though lists are mutable. How does this differ from a function that uses data.append(100) instead? Connect your answer to the distinction between mutation and reassignment.Show answer
[1, 2, 3] for nums and [1, 2, 3, 100] for result. The expression data + [100] uses the + operator, which creates a brand-new list by concatenating the two lists. The assignment data = ... then rebinds the local variable data to point at that new list, leaving the original object untouched. This is reassignment, not mutation. By contrast, data.append(100) is mutation — it reaches into the existing list object and adds an element in place, which would affect nums because both names still point to the same object. The key insight is that the = operator changes what a variable references, while methods like .append() change the object itself.SUMMARY
Lesson Summary
Summary
Python lists are mutable, meaning their contents can be changed in place without creating a new object. When you assign one variable to another with b = a, both variables become aliases — references pointing to the same list object in memory. Any in-place modification (such as .append(), .sort(), or index assignment) through one alias will be visible through every other alias to the same object.
To create an independent copy, use shallow copy techniques such as a[:], list(a), or a.copy(). Always distinguish between mutation (changing the object) and reassignment (pointing a variable at a new object). When passing lists to functions, remember that the parameter is an alias — if the function mutates it, the caller's data changes too. Mastering these concepts now will prevent some of the most frustrating bugs you'll encounter as a programmer.