Python Mutable vs Immutable: How It Impacts Your Code
Understand how Python's mutable and immutable types behave differently in assignment, function calls, and memory usage, and how to choose the right type for your data.
Every Python object is either mutable or immutable. Whether an object’s value can change after creation determines whether operations create new objects or modify existing ones in place. This distinction affects function arguments, data sharing, copying, and performance. Consider a simple example:
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4]
Now compare that with a tuple:
a = (1, 2, 3) b = a # b[0] = 99 # TypeError: 'tuple' object does not support item assignment
The list changed because lists are mutable. The tuple could not change because tuples are immutable. This core difference influences how you write, debug, and optimize Python code.
What Makes an Object Mutable or Immutable
In Python, every object has a type, an identity, and a value. Mutability refers to whether the value can change after the object is created. If the value can be altered in place, the object is mutable. If the value is fixed at creation time, the object is immutable.
The distinction is enforced at the language level. For immutable types, any operation that appears to modify the object actually creates a new object and rebinds the name. For mutable types, operations like append, update, or __setitem__ change the object's internal state without changing its identity.
This behavior is not just an implementation detail. It is part of Python's object model and has direct consequences for how data flows through your program.
Mutable Types in Python
The most common mutable types are list, dict, set, and bytearray. These types provide methods that modify the object in place:
items = [1, 2, 3] items.append(4) # list modified in place config = {"host": "localhost"} config["port"] = 8080 # dict modified in place unique = {1, 2, 3} unique.add(4) # set modified in place
When you pass a mutable object to a function, the function receives a reference to the same object. Changes made inside the function are visible outside it, unless the function explicitly creates a copy.
def add_item(lst): lst.append(99) my_list = [1, 2] add_item(my_list) print(my_list) # [1, 2, 99]
This behavior is useful when you want to share state, but it also introduces the risk of unintended side effects.
Immutable Types in Python
Immutable types include int, float, str, tuple, frozenset, and bytes. Once created, their own structure cannot be changed. Operations that look like modification return a new object instead:
name = "python" new_name = name.upper() # creates a new string print(name) # "python" print(new_name) # "PYTHON" point = (1, 2) # point[0] = 3 # TypeError
One nuance is that immutable means the container itself cannot change, not that every value inside it is frozen. A tuple cannot add, remove, or replace its elements, but if one of those elements is a mutable object such as a list, that list can still be modified. For example:
t = ([1, 2],) t[0].append(3) print(t) # ([1, 2, 3],)
Even though integers are immutable, you can reassign a variable to a different integer. That does not change the original integer object; it rebinds the name to a new object. The old object becomes eligible for garbage collection if no other references exist.
Immutable objects prevent changes to their own structure. For immutable objects that are also hashable, this means their hash value remains stable, making them safe to share across threads, use as dictionary keys, and store in sets.
How Assignment and References Work
When you write b = a, you are not copying the object. You are copying the reference. Both names point to the same object in memory. For mutable objects, this means changes through one name affect the other. For immutable objects, this aliasing is harmless at the top level, except when an immutable container holds a mutable object, as shown above.
a = [1, 2, 3] b = a print(a is b) # True c = (1, 2, 3) d = c print(c is d) # True
The is operator checks identity, not value. For immutable objects, Python sometimes reuses existing objects (like small integers), but that is an implementation optimization and not something you should rely on for correctness.
Understanding references is critical when you need to copy a mutable object. A shallow copy using copy.copy or list.copy() creates a new container but shares the nested mutable elements. A deep copy with copy.deepcopy recursively copies everything. Choosing the right copy method depends on whether you need to isolate the entire structure or just the top level.
Implications for Function Arguments and Defaults
The mutability of an object directly affects how it behaves as a function argument. If you pass a mutable object and modify it inside the function, the caller sees the change. This can be intentional, but it often leads to bugs when the function is not expected to mutate its input.
A classic pitfall is using a mutable default argument:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2] # unexpected!
The default list is created once when the function is defined, not each time the function is called. Because lists are mutable, subsequent calls reuse the same list. The fix 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
Defaults made from immutable values, such as strings or plain tuples, do not have this problem because they cannot be modified in place.
Performance and Memory Considerations
Mutability has practical performance implications. Mutable objects can be modified in place, avoiding the overhead of creating a new object each time. For example, appending to a list is amortized O(1). Repeated string concatenation can become quadratic in a naive loop, because each += may create a new string and copy the existing characters.
# Inefficient string building s = "" for i in range(1000): s += str(i) # Better: use a list and join parts = [] for i in range(1000): parts.append(str(i)) s = "".join(parts)
Immutable objects, however, are easier to cache and share. Because they cannot change, Python can safely reuse the same object across multiple references. This reduces memory usage when the same value appears many times, such as small integers and interned strings in CPython.
Hashable immutable objects have a stable hash, which makes them suitable as dictionary keys. Mutable objects like lists are unsuitable because a mutable value cannot provide a stable hash; if it changed after insertion, the dictionary's invariants would break.
Choosing Between Mutable and Immutable Types
The choice between mutable and immutable types should be driven by how the data is used. Use a mutable type when you need to build or modify a collection incrementally, or when you want to share state across functions. Use an immutable type when the object's structure should remain fixed after creation, or when you need a stable hash for use as a dictionary key or set element.
Tuples are often used for fixed records, such as coordinates or database rows. Lists are used for dynamic sequences. frozenset provides an immutable set for cases where you need set operations but want to guarantee the contents never change.
In data-heavy applications, immutable values can simplify reasoning about concurrency because their structure cannot change after creation. However, they may incur higher allocation overhead if you need to create many modified copies. The right choice depends on the specific pattern: if you frequently append to a collection, a list is more efficient; if you need to pass data around without worrying about accidental modification, a tuple of immutable values or a custom immutable wrapper is safer.
Common Pitfalls and How to Avoid Them
One common mistake is assuming that += on a tuple or string modifies the original. It does not; it creates a new object and rebinds the name. This can be surprising when you expect in-place behavior.
Another pitfall is modifying a list while iterating over it. Because the list is mutable, removing or adding items during iteration can cause elements to be skipped or raise RuntimeError. A safer approach is to iterate over a copy or build a new list.
# Risky: modifying list during iteration numbers = [1, 2, 3, 4] for n in numbers: if n % 2 == 0: numbers.remove(n) # Safer: build a new list numbers = [n for n in numbers if n % 2 != 0]
Finally, be careful when storing mutable objects in a set or as dictionary keys. Even if you use a custom class that is technically hashable, if the object is mutable and its hash changes, the set or dictionary will behave incorrectly. Prefer immutable types for keys, or ensure your mutable objects do not change their hash after insertion.
Understanding the mutable vs immutable distinction is not just about knowing which types are which. It is about predicting how your data will behave when shared, copied, or passed around. By choosing the right kind of object for each situation, you avoid subtle bugs and write code that is both efficient and maintainable.