Python Mutable vs Immutable Types: Behavior and Tradeoffs
python mutable vs immutable types: Explains how Python's mutable and immutable types differ in assignment, aliasing, hashing, and memory behavior, with practical code...
Python's distinction between mutable and immutable types determines how values behave when assigned, passed to functions, and stored in containers. A mutable object can change its contents after creation; an immutable object cannot. This is not an abstract language detail — it affects aliasing, function arguments, dictionary keys, and memory behavior. The python mutable vs immutable types distinction is one of the first places where Python's reference semantics become visible in practice.
What Mutable and Immutable Actually Mean in Python
In Python, every variable is a name bound to an object. The object has an identity (its address in memory), a type, and a value. When you write x = [1, 2], x is a reference to a list object. When you write y = x, y refers to the same object — no copy is made.
x = [1, 2] y = x y.append(3) print(x) # [1, 2, 3]
Both names point to the same list. Mutating through y is visible through x. With an immutable type, this cannot happen:
a = "hello" b = a b += " world" print(a) # "hello" print(b) # "hello world"
The += operation creates a new string object and rebinds b. The original string a is untouched. This is the core behavioral difference: mutation changes the object in place, while immutability forces the creation of a new object for any change.
The Immutable Types and Their Boundaries
Python's immutable built-in types include int, float, complex, bool, str, bytes, tuple, frozenset, and range. Once created, their value cannot be modified. Any operation that appears to change them actually produces a new object.
s = "abc" s_upper = s.upper() print(s) # "abc" print(s_upper) # "ABC"
str.upper() returns a new string. The original remains unchanged. The same applies to numeric operations:
n = 5 n += 1 print(n) # 6
The + operator creates a new integer object with value 6 and rebinds n. The original integer 5 is no longer referenced and becomes eligible for garbage collection.
Tuples deserve special attention because they can contain mutable objects:
t = ([1, 2], 3) t[0].append(99) print(t) # ([1, 2, 99], 3)
The tuple itself is immutable — you cannot reassign t[0] or t[1]. But the list inside it is mutable, so its contents can change. Tuple immutability applies to the tuple's references, not to the objects those references point to.
The Mutable Types and Their Consequences
The mutable built-in types are list, dict, set, and bytearray. They support in-place modification through methods like append, extend, update, add, and remove, as well as through item assignment.
data = [1, 2, 3] data[0] = 100 print(data) # [100, 2, 3]
The same list object is modified in place; its identity (id(data)) does not change. This is why mutable objects are dangerous as default arguments:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2]
The default list is created once at function definition time and reused across all calls. The second call sees the result of the first. The standard 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
This is the most common production bug caused by mutable defaults.
Why Immutability Matters for Dictionary Keys and Sets
Hashable objects are required for dictionary keys and set members. In Python, an object is hashable if its hash value never changes during its lifetime. Mutable objects are unhashable because their value can change, which would break the hash table invariant.
d = {} d[[1, 2]] = "value" # TypeError: unhashable type: 'list'
Tuples are hashable only if every element is hashable:
d = {} d[(1, 2)] = "ok" # works d[(1, [2])] = "broken" # TypeError: unhashable type: 'list'
This is a direct practical consequence of the mutable/immutable distinction. If you need a composite key, you must ensure all components are immutable.
Runtime Cost and Memory Behavior
Immutable objects are generally cheaper to share. Since they cannot change, Python can safely reuse small integers and interned strings without risking aliasing bugs. For example, small integers in the range -5 to 256 are cached by the interpreter:
a = 100 b = 100 print(a is b) # True
Larger integers are not cached:
a = 1000 b = 1000 print(a is b) # False (implementation detail, not guaranteed)
This caching is an implementation detail of CPython and should not be relied upon in production code. The is operator compares identity, not value; use == for value comparison.
Mutable objects require more care with memory because every shared reference is a potential mutation point. Copying a list with list.copy() or copy.deepcopy() has a real cost, and deep copies of nested structures can be expensive. When you need to protect internal state, returning a copy rather than the internal object is a common pattern:
class Buffer: def __init__(self): self._items = [] def items(self): return self._items.copy()
This prevents callers from mutating the internal list directly. The cost is a copy per call, which is acceptable for most use cases but worth considering in hot paths.
Choosing Between Mutable and Immutable Types
The choice is not always obvious. Use immutable types when:
- The value represents a fixed fact, such as a configuration constant or a coordinate pair.
- The value is used as a dictionary key or set member.
- The value is shared across threads and must not change.
- You want to prevent accidental modification through aliases.
Use mutable types when:
- The data grows or changes over time, such as accumulating results or building a collection incrementally.
- You need efficient in-place updates without allocating new objects.
- The object represents a stateful structure like a queue or a cache.
For data that should not change but is expensive to copy, consider returning a read-only view. Python's types.MappingProxyType provides a read-only view of a dictionary:
from types import MappingProxyType config = {"host": "localhost", "port": 8080} read_only = MappingProxyType(config) read_only["port"] = 9090 # TypeError: 'mappingproxy' object does not support item assignment
The underlying dictionary can still be changed through the original reference, so this is a shallow protection, not a deep freeze.
Common Mistakes and Edge Cases
One frequent mistake is assuming that += behaves the same for mutable and immutable types. For immutable types, += creates a new object. For mutable types, it mutates in place:
a = [1, 2] b = a a += [3] print(b) # [1, 2, 3] — same object, mutated c = (1, 2) d = c c += (3,) print(d) # (1, 2) — original tuple unchanged
Another edge case is the interaction between immutability and equality. Two distinct immutable objects can compare equal:
x = (1, 2) y = (1, 2) print(x == y) # True print(x is y) # False
Equality and identity are separate concepts. Immutability guarantees that a value does not change, but it does not guarantee that two equal objects are the same object.
When designing your own classes, you can choose mutability by deciding whether methods mutate self or return new instances. Dataclasses with frozen=True provide an easy way to create immutable value objects:
from dataclasses import dataclass @dataclass(frozen=True) class Point: x: int y: int
Attempting to assign to point.x raises FrozenInstanceError. This gives you immutability similar to a tuple but with named fields and generated __eq__ and __repr__ methods. The tradeoff is that frozen dataclasses are slightly more verbose than plain tuples, and they still cannot contain mutable fields if you need full hashability for use as dictionary keys.