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.
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.
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
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.
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 |
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.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+)
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.
scores = [85, 90, 78]. Python creates a new list object in memory and assigns the name scores to point at it.backup = scores. This does NOT copy the list. The variable backup now points to the exact same object as scores. We can verify: scores is backup returns True.safe_copy = scores[:]. This creates a brand-new list object containing the same values. Now scores is safe_copy returns False, but scores == safe_copy returns True.scores[1] = 95. This is an in-place modification — the list at id 1000 now holds [85, 95, 78]. Since backup also points to id 1000, it sees the change. But safe_copy points to id 2000 and is unaffected.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.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 |
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 |
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.
Practice Problems
x = [1, 2, 3]
y = x
y.append(4)
print(x)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)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?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.Lesson 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.