Mutable vs Immutable in Python: Common Pitfalls Explained

· 7 min read

Introduction

Understanding mutable vs immutable objects is one of the biggest turning points for Python beginners.

Many confusing Python bugs come from not realizing whether an object can change in place or whether Python silently creates a new object behind the scenes. These bugs are especially painful because the code often looks correct at first glance.

If you have ever seen a variable change value without being explicitly assigned, this article explains why.

This article covers:

  • What mutable and immutable objects actually are
  • Why the distinction matters
  • The most common real-world pitfalls
  • How to avoid subtle bugs in your own code

The goal is not memorizing definitions. The goal is understanding why certain lines of Python behave unexpectedly.


What Does Mutable Mean?

A mutable object can be changed after it is created.

In Python, common mutable types include:

  • list
  • dict
  • set

The important detail is that the object itself stays the same object in memory while its contents change.

Example: Modifying a List

numbers = [1, 2, 3]

numbers[0] = 99

print(numbers)

Expected output:

[99, 2, 3]

The list was modified in place. You did not create a new list. You changed the existing object.

That behavior is extremely useful because mutable objects allow efficient updates without constantly allocating new memory. Appending items to a list, updating a dictionary, or adding values to a set would all be painfully inefficient if Python recreated a brand-new object every time.


What Does Immutable Mean?

An immutable object cannot be modified after creation.

Common immutable types include:

  • int
  • float
  • str
  • tuple
  • bool
  • frozenset

With immutable objects, any “change” actually creates a completely new object.

Example: Strings Cannot Be Modified

name = "Alice"

name[0] = "B"

Expected output:

TypeError: 'str' object does not support item assignment

Strings do not allow in-place modification.

Beginners often try this instead:

name = "Alice"

name = "B" + name[1:]

print(name)

Expected output:

Blice

But this is not modifying the original string. Python creates a completely new string object and reassigns the variable name to point to it. That distinction matters a lot later when dealing with memory, references, and function behavior.


The Key Difference: Identity vs Value

The easiest way to understand mutability is by looking at object identity. Python provides the id() function, which returns the identity of an object in memory.

Mutable Objects Keep the Same Identity

a = [1, 2, 3]

print(id(a))

a.append(4)

print(id(a))

Example output:

140234567
140234567

The contents changed, but the object identity stayed the same. The list itself is still the same object.

Immutable Objects Create New Identities

s = "hello"

print(id(s))

s = s + " world"

print(id(s))

Example output:

140234999
140235120

The identity changed because Python created a new string object.

This difference explains many “weird” Python behaviors:

  • With mutable objects, multiple variables can reference the same object, so modifying one variable may affect another
  • With immutable objects, “changes” create new objects instead, making accidental shared modifications impossible

That is why immutable objects are generally safer to pass around.


Common Pitfalls

This is where mutable vs immutable behavior starts causing real bugs.


Pitfall 1: Mutable Default Arguments

This is one of the most famous Python pitfalls.

Dangerous version:

def add_item(item, items=[]):

    items.append(item)

    return items


print(add_item("apple"))

print(add_item("banana"))

Expected output:

['apple']
['apple', 'banana']

Most beginners expect the second call to return ['banana']. But it does not.

Why? Because default arguments are created only once when the function is defined — not every time the function runs. That means the [] list is shared across all function calls, so every call modifies the same list object.

Correct version:

def add_item(item, items=None):

    if items is None:
        items = []

    items.append(item)

    return items


print(add_item("apple"))

print(add_item("banana"))

Expected output:

['apple']
['banana']

This works because a new list is created each time items is None. This pattern appears constantly in professional Python code.


Pitfall 2: Copying Lists (Shallow vs Deep)

Another major source of bugs is accidental shared references.

Looks like a copy — but isn’t:

original = [1, 2, 3]

copy = original

copy.append(99)

print(original)

Expected output:

[1, 2, 3, 99]

Why did original change? Because copy = original does not create a new list. Both variables point to the exact same object. You created a second reference, not a copy.

Correct ways to copy:

Using .copy():

original = [1, 2, 3]

copy = original.copy()

copy.append(99)

print(original)

Expected output:

[1, 2, 3]

Using slice syntax:

copy = original[:]

Both create a shallow copy of the list.

The nested list problem:

Things become trickier with nested structures.

original = [[1, 2], [3, 4]]

copy = original.copy()

copy[0].append(99)

print(original)

Expected output:

[[1, 2, 99], [3, 4]]

Even though you copied the outer list, the inner lists are still shared. That is because .copy() performs a shallow copy — it copies the outer container but not the objects inside it.

Deep copy:

For nested mutable structures, use deepcopy().

import copy

original = [[1, 2], [3, 4]]

duplicate = copy.deepcopy(original)

duplicate[0].append(99)

print(original)

Expected output:

[[1, 2], [3, 4]]

deepcopy() recursively copies nested objects instead of sharing references.


Pitfall 3: Tuples Containing Mutable Objects

Beginners often assume tuples are completely immutable. Not exactly.

The tuple structure is immutable, but objects inside the tuple may still be mutable.

t = ([1, 2], [3, 4])

t[0].append(99)

print(t)

Expected output:

([1, 2, 99], [3, 4])

Reassigning an element inside the tuple would raise an error:

t[0] = [5, 6]

Expected output:

TypeError: 'tuple' object does not support item assignment

The tuple itself cannot be reassigned, but the inner lists are mutable objects, so they can still change. This distinction becomes important when storing mutable objects inside supposedly “safe” containers.


Why Does This Design Exist?

Python’s mutable/immutable distinction is not arbitrary. It enables important language features and performance optimizations.

Immutable Objects Can Be Dictionary Keys

Dictionary keys must be hashable. Mutable objects are not hashable because their contents can change, which would break dictionary lookups.

d = {}

d[(1, 2)] = "tuple key works"

print(d)

Expected output:

{(1, 2): 'tuple key works'}
d = {}

d[[1, 2]] = "list key fails"

Expected output:

TypeError: unhashable type: 'list'

If mutable objects were allowed as keys, changing them later would silently corrupt the dictionary.

Immutable Objects Are Safer

Immutable objects are naturally safer for:

  • Multithreading (no risk of one thread modifying shared state)
  • Caching and hashing
  • Passing values between functions without side effects

Since they cannot change unexpectedly, bugs become easier to reason about.

Small Python Optimization: String Interning

Python sometimes reuses immutable string objects internally to save memory.

a = "hello"

b = "hello"

print(a is b)

Example output:

True

Note: is checks object identity (same object in memory), while == checks value equality. Python may reuse the same string object for identical short strings — this is called string interning. That optimization would be impossible with mutable objects.


Quick Reference Table

TypeMutable?Example
list✅ Yes[1, 2, 3]
dict✅ Yes{"a": 1}
set✅ Yes{1, 2, 3}
int❌ No42
float❌ No3.14
str❌ No"hello"
tuple❌ No(1, 2)
frozenset❌ Nofrozenset({1, 2})

Wrap-Up

There are three key ideas worth remembering:

  1. Mutable objects can change in place.
  2. Immutable objects create new objects when “modified.”
  3. Most confusing Python bugs come from accidentally sharing mutable objects.

If a variable changes “mysteriously,” check whether multiple references point to the same mutable object.

Understanding this topic makes Python behavior far more predictable — especially when working with functions, copies, dictionaries, and nested data structures.

List comprehensions — covered in the list comprehensions guide — are one of the most common places where mutable object behavior matters, particularly when building new lists from existing ones. The dictionary methods guide shows these concepts in action — particularly why dict keys must be hashable and how copying nested dictionaries works. For a deeper look at how reference counting connects to object lifetimes and memory management, see the garbage collector deep dive. For a practical next step, the openpyxl tutorial shows mutable objects like lists and dictionaries in action.