Python List Mutability: Assignment and Function Calls
python list mutability: Learn how Python list mutability affects assignment, function calls, and copying, and avoid common pitfalls like shared references and mutable...
In Python, lists are mutable objects: you can change their contents in place without creating a new list. This behavior is convenient, but it also means that the same list object can be referenced from multiple places, and changes made through one reference are visible through all others. Understanding python list mutability is essential for writing predictable code, especially when passing lists to functions or storing them in data structures.
What Does Mutability Mean for a List?
A mutable object can be modified after it is created. For lists, this includes changing an element by index, appending, removing, extending, and sorting in place. Consider this simple example:
nums = [1, 2, 3] nums.append(4) nums[0] = 0 print(nums) # [0, 2, 3, 4]
The list object nums is changed directly; no new list is created. This is different from immutable types like strings and tuples, where any operation that appears to modify the object actually returns a new object.
How Assignment Creates References, Not Copies
When you assign a list to another variable, you are not creating a copy. Both variables refer to the same list object. This is often called aliasing. For example:
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4]
Because a and b point to the same list, the mutation through b is visible through a. To create an independent copy, use the copy() method, the list() constructor, or slicing:
a = [1, 2, 3] b = a.copy() b.append(4) print(a) # [1, 2, 3] print(b) # [1, 2, 3, 4]
These methods produce a shallow copy: the list container is new, but the elements are still shared. For lists of immutable objects (like integers or strings), this is usually sufficient.
Passing Lists to Functions: The Caller Is Not Safe
When you pass a list to a function, the function receives a reference to the same object. If the function mutates the list, the caller sees the change. This can be intentional, but it often leads to bugs when you expect the function to leave the input unchanged.
def add_item(lst, item): lst.append(item) my_list = [1, 2] add_item(my_list, 3) print(my_list) # [1, 2, 3]
To avoid modifying the original, you can pass a copy or have the function return a new list. The choice depends on the design. If the function is meant to transform data, returning a new list is often clearer:
def add_item_copy(lst, item): new_lst = lst.copy() new_lst.append(item) return new_lst
Avoiding Unintended Mutation: Copying Lists
As mentioned, shallow copies share the same elements. For nested lists, this can cause surprising behavior. Consider:
original = [[1, 2], [3, 4]] shallow = original.copy() shallow[0].append(99) print(original) # [[1, 2, 99], [3, 4]]
The inner list is shared between original and shallow. To create a fully independent copy, use copy.deepcopy():
import copy original = [[1, 2], [3, 4]] deep = copy.deepcopy(original) deep[0].append(99) print(original) # [[1, 2], [3, 4]]
Deep copying recursively creates new objects for all nested elements. It is more expensive than a shallow copy, so use it only when necessary.
Mutability and Memory: The Tradeoff
Mutating a list in place avoids allocating a new list, which can be more efficient when working with large collections. For example, repeatedly appending to a list is generally cheaper than concatenating lists, which creates a new list each time. However, aliasing can lead to unintended memory sharing. If you copy a list, you create a new container, but the elements are still shared unless you deep copy. This means that modifying an element in one list can affect another if the element itself is mutable.
Consider the performance implication: a shallow copy of a list with a million integers is cheap because it only copies references. A deep copy would be much more expensive because it creates new integer objects (though integers are immutable, so deep copy may not be needed). In practice, you should choose the copying strategy based on whether the elements are mutable and whether you need independent copies.
Common Pitfalls: Default Arguments and Class Attributes
A classic mistake is using a mutable default argument in a function definition:
def add_item(item, lst=[]): lst.append(item) return lst
The default list is created once when the function is defined and shared across all calls. This leads to unexpected accumulation:
print(add_item(1)) # [1] print(add_item(2)) # [1, 2]
The correct pattern is to use None and create a new list inside the function:
def add_item(item, lst=None): if lst is None: lst = [] lst.append(item) return lst
Similarly, class attributes that are lists are shared across instances. If you define a list at the class level and mutate it through an instance, all instances see the change. Use instance attributes instead:
class MyClass: def __init__(self): self.items = [] # instance attribute
When to Choose Immutable Alternatives
Sometimes you want to guarantee that a sequence cannot be modified. Tuples are immutable and can be used where a fixed collection is required. They also can be used as dictionary keys or set elements, which lists cannot. The table below summarizes the key differences:
| Feature | List | Tuple |
|---|---|---|
| Mutability | Mutable | Immutable |
| Methods for modification | append, remove, etc. | None |
| Hashable | No | Yes (if elements are hashable) |
| Typical use | Dynamic collections | Fixed records, function arguments |
If you need to pass a collection to a function and want to prevent accidental modification, consider using a tuple. However, if you need to modify the collection, a list is appropriate. The choice depends on whether the data is expected to change.