Welcome back to our deep dive into Python's variables! In our first post, we established a crucial mental model: variables are names bound to objects. 🏷️
Now, let's use that model to tackle one of Python's most consequential concepts: mutability. This is the key to understanding why some objects change under your feet while others seem to create new copies. Let's unravel the mystery. 🧶
The Great Divide: Mutable vs. Immutable Types
An object's mutability is a fundamental property of its type.
- Immutable Objects: Cannot be changed after they are created. Any operation that seems to "change" it actually creates a brand new object.
-
int
,float
,str
,tuple
,frozenset
,bool
-
- Mutable Objects: Can be changed in-place. Operations can modify the object's contents without creating a new one.
-
list
,dict
,set
,bytearray
-
A Tale of Two Operations
The difference reveals itself in operations like +=
. Let's see it in action:
# Example 1: Working with an IMMUTABLE integer
x = 5
print(f"Original ID of x: {id(x)}")
x += 1 # Creates a new object!
print(f"ID of x after +=: {id(x)}") # New ID!
print(f"Value of x: {x}\n")
# Example 2: Working with a MUTABLE list
my_list = [1, 2, 3]
print(f"Original ID of my_list: {id(my_list)}")
my_list += [4] # Modifies the object in-place!
print(f"ID of my_list after +=: {id(my_list)}") # Same ID!
print(f"Value of my_list: {my_list}")
Output:
Original ID of x: 4391331008
ID of x after +=: 4391331040 # ✅ ID changed (new int object)
Value of x: 6
Original ID of my_list: 140248578401088
ID of my_list after +=: 140248578401088 # ✅ ID is the same (modified in-place)
Value of my_list: [1, 2, 3, 4]
The +=
operator behaves differently based on mutability! For lists, it's an in-place operation. For integers, it's a reassignment.
Visualizing the Difference
This diagram shows the core distinction. An immutable operation creates a new object and moves the name. A mutable operation changes the original object in-place.
IMMUTABLE (e.g., int) MUTABLE (e.g., list)
x --> [5] my_list --> [1, 2, 3]
x = x + 1 my_list += [4]
x --> [6] (New Object!) my_list --> [1, 2, 3, 4] (Same Object!)
The Practical Pitfalls: Where Theory Meets (and Breaks) Code
Understanding this isn't just academic; it prevents real, head-scratching bugs. 🐛
1. Mutable Default Arguments: The Classic Gotcha
Consider this function:
def add_task(new_task, tasks=[]): # 🚨 DANGER! Mutable default.
tasks.append(new_task)
return tasks
What happens when we call it?
print(add_task("Write report")) # Output: ['Write report']
print(add_task("Send email")) # Output: ['Write report', 'Send email'] 😲
Why? The default list tasks=[]
is created once, when the function is defined. Every subsequent call that uses the default value reuses that same original list object. You're mutating the same list every time!
The Fix: Always use None
for mutable defaults.
def add_task(new_task, tasks=None):
if tasks is None:
tasks = [] # Create a new list on each call
tasks.append(new_task)
return tasks
2. The Copying Conundrum
Remember, assignment just creates new names for the same object. To actually create a separate copy of a mutable object, you must be explicit.
# Shallow vs. Deep Copying
original = [1, 2, [3, 4]]
shallow_copy = original.copy() # or list(original) or original[:]
# Let's modify the nested list in the original
original[2].append(5)
print("Original:", original) # [1, 2, [3, 4, 5]]
print("Shallow Copy:", shallow_copy) # [1, 2, [3, 4, 5]] 😲 The nested list is shared!
For structures with nested mutable objects, you need a deepcopy
from the copy
module to create a fully independent clone.
from copy import deepcopy
original = [1, 2, [3, 4]]
deep_copy = deepcopy(original)
original[2].append(5)
print("Original:", original) # [1, 2, [3, 4, 5]]
print("Deep Copy:", deep_copy) # [1, 2, [3, 4]] ✅ Fully independent!
A Subtle Nuance: Immutable Containers of Mutable Objects
A tuple
is immutable, meaning you cannot add, remove, or replace the items it contains. However, if one of those items is itself mutable, the contents of that item can be changed.
my_tuple = (1, 2, [3, 4]) # An immutable tuple holding a mutable list.
# my_tuple[0] = 10 # ❌ This would fail! Can't modify the tuple.
my_tuple[2].append(5) # ✅ This is allowed! The list is mutable.
print(my_tuple) # (1, 2, [3, 4, 5])
Key Takeaway 🗝️
The type of object a variable references—mutable or immutable—dictates how it behaves when you try to change it. Mutables change in-place, affecting all names pointing to them. Immutables force the creation of new objects, leaving the original untouched.
Mastering this distinction is non-negotiable for writing predictable and robust Python code. It explains so much, from function arguments to memory efficiency.
In our final post, we'll see how these concepts of names and objects play out within the rules of Scope and Namespaces, controlling exactly where your variables are visible and modifiable.
To ponder before next time: Why does this function not change the variable x
passed into it?
def try_to_change(x):
x = x + 1
my_var = 10
try_to_change(my_var)
print(my_var) # Output is still 10. Why?
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)