DEV Community

Cover image for Python Mutability, Immutability, and Their Consequences
Aaron Rose
Aaron Rose

Posted on

Python Mutability, Immutability, and Their Consequences

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}")
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What happens when we call it?

print(add_task("Write report")) # Output: ['Write report']
print(add_task("Send email"))   # Output: ['Write report', 'Send email'] 😲
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)