Back to Blog
Python

Python Mutable Data Types: Behavior and Pitfalls

python mutable data types: Learn how Python mutable data types like lists and dicts behave, why they cause surprising side effects, and how to copy them correctly.

PythonMutableImmutableAliasingCopyingFunction Arguments
Diagram showing two variables referencing the same mutable list object, with a mutation symbol indicating in-place change.

In Python, a data type is mutable when its value can change after the object is created. Lists, dictionaries, sets, and byte arrays are mutable; strings, tuples, and frozensets are not. This distinction matters because mutable objects can be modified in place, and those modifications are visible through every reference to the same object. Understanding python mutable data types is essential for writing predictable code, especially when passing objects to functions or storing them in data structures.

What Makes a Data Type Mutable in Python

Mutability is a property of the object, not the variable. When you assign a list to a variable, that variable holds a reference to the list object. The list object itself can be changed: you can append an item, remove an element, or reassign an index. These operations modify the same object in memory. In contrast, an immutable object like a tuple cannot be changed after creation. Any operation that appears to modify a tuple actually creates a new tuple object.

The built-in mutable types are:

  • list
  • dict
  • set
  • bytearray

User-defined classes are mutable by default unless you deliberately implement immutability, for example by overriding __setattr__ or using frozen dataclasses. The distinction is not just academic; it directly affects how data flows through your program.

Mutable vs Immutable Types: A Practical Comparison

The following table summarizes common built-in types and their mutability:

TypeMutableExample operationResult
listYeslst.append(4)Modifies the existing list object
dictYesd['key'] = 'value'Adds or updates the existing dictionary
setYess.add(3)Modifies the existing set object
tupleNot + (4,)Creates a new tuple
strNos.upper()Returns a new string
frozensetNo`fs{1}`

When you perform an operation on an immutable object, the original object remains unchanged. This behavior is useful when you want to guarantee that a value won't change unexpectedly, for example as a dictionary key. Mutable objects cannot be used as dictionary keys because their hash value would change if the object were modified, breaking the dictionary's internal structure.

Aliasing: When Two Names Point to the Same Object

Assigning a mutable object to another variable does not copy the object; it copies the reference. Both variables now refer to the same underlying object. This is called aliasing.

original = [1, 2, 3] reference = original reference.append(4) print(original) # [1, 2, 3, 4]

Here, reference and original are two names for the same list. Mutating through either name affects the other. This is often the source of subtle bugs, especially when you pass a list to a function and the function modifies it. The caller's list is changed even if the function does not return anything.

Aliasing is not always a problem. Sometimes you deliberately want shared state, for example when building a graph where multiple nodes reference the same shared collection. But when you expect independent copies, you need to explicitly copy the object.

Copying Mutable Objects: Shallow vs Deep

To create an independent copy of a mutable object, you can use the copy module or the type's own copy method. A shallow copy creates a new container object but populates it with references to the same elements. A deep copy recursively copies the elements themselves.

import copy original = [[1, 2], [3, 4]] shallow = copy.copy(original) deep = copy.deepcopy(original) shallow[0].append(99) print(original) # [[1, 2, 99], [3, 4]] # changed because inner list is shared deep[0].append(100) print(original) # [[1, 2, 99], [3, 4]] # unchanged

A shallow copy is sufficient when the container holds immutable elements, or when you only need to change the container structure (e.g., adding or removing top-level items). A deep copy is required when you need to fully isolate nested mutable structures. Deep copies are more expensive and can fail if the object graph contains recursive references or non-copyable resources like open file handles.

For lists, you can also use slicing (original[:]) or list(original) to create a shallow copy. For dictionaries, dict(original) works. For sets, set(original) works. These are convenient but still shallow.

The Mutable Default Argument Trap

A common mistake is using a mutable object as a default argument value in a function definition. Default arguments are evaluated only once, at function definition time, not on every call. If you use a mutable default, all calls that rely on the default will share the same object.

def add_item(item, items=[]): items.append(item) return items first = add_item(1) second = add_item(2) print(first) # [1, 2] print(second) # [1, 2]

The list items is created once and reused across all calls. The correct pattern is to use None as the default and create a new list inside the function:

def add_item(item, items=None): if items is None: items = [] items.append(item) return items

This ensures each call gets a fresh list unless the caller explicitly passes one. The same applies to dictionaries and sets used as defaults.

Mutable Objects as Function Arguments: Side Effects

When you pass a mutable object to a function, the function receives a reference to the same object. Any in-place modification inside the function is visible to the caller. This can be intentional, but it often leads to unexpected behavior if the function is supposed to be pure.

def add_to_list(lst, value): lst.append(value) my_list = [1, 2] add_to_list(my_list, 3) print(my_list) # [1, 2, 3]

If you want to avoid modifying the caller's object, you should copy the object inside the function before making changes. For example, lst = lst.copy() or lst = list(lst) at the start of the function. This is a design decision: sometimes you want the side effect, but you should document it clearly and make it explicit in the function name, e.g., append_to_list versus with_appended.

Performance and Memory Considerations

Mutability has performance implications. Modifying a mutable object in place avoids allocating a new object, which can save memory and reduce garbage collection pressure. For large collections, repeated concatenation of immutable sequences like strings or tuples creates a new object each time, leading to O(n^2) behavior in loops. The same is not true for lists, which amortize append operations.

# Inefficient for large n s = "" for i in range(10000): s += str(i) # Better: use a list and join parts = [] for i in range(10000): parts.append(str(i)) s = "".join(parts)

However, mutability also makes it harder to reason about code because the same object can be changed from multiple places. In concurrent or multi-threaded programs, shared mutable state requires locks or careful synchronization. Immutable objects are naturally thread-safe because they cannot change. When you need to share data across threads, immutable tuples or frozensets are safer choices.

Choosing Between Mutable and Immutable Types

The decision between mutable and immutable types depends on your data access pattern and the guarantees you need. Use mutable types when you need to update a collection in place frequently, such as building a list incrementally or maintaining a cache. Use immutable types when you need a stable hashable value for dictionary keys, when you want to prevent accidental modification, or when you are passing data across trust boundaries where you don't want the receiver to change it.

For small, fixed collections, a tuple is often a better choice than a list because it is immutable and slightly more memory-efficient. For sets that need to change, use set; if you need a hashable set, use frozenset. For dictionaries, there is no built-in immutable variant, but you can use types.MappingProxyType to create a read-only view, or use a named tuple structure if the keys are fixed.

When designing APIs, consider whether the caller will expect to see changes. If you return a mutable object from a function, the caller can modify it, which may break encapsulation. Returning a copy or an immutable representation can protect internal state. This is a common pattern in libraries that expose cached data or configuration objects.

python mutable data types: Practical Usage and Code Examples | RYUSLOG DEV