Definition
You call a method on a string, you run the program again, and the string is exactly as it was. No error, no warning, and yet the result you expected is nowhere to be found. This is not a bug: it is immutability.
An immutable object is an object whose contents can no longer change once it exists. No operation modifies it in place: the ones that look like they do build a new object and leave the old one perfectly intact. Numbers, booleans, strings and tuples all belong to that family.
Three lines are enough to watch the mechanism at work.
text = "believemy"
text.upper() # builds "BELIEVEMY" and hands it straight back
print(text) # believemy, nothing movedThe method did work, but it handed its result back instead of writing it into text. Since nobody stored that result, it vanishes on the very next line; keeping it means naming it, with text = text.upper(). Hence the habit to acquire: on an immutable object, a method whose result you ignore is a call for nothing.
Building a new object for every operation eventually costs: concatenating a string inside a ten thousand round loop creates ten thousand intermediate strings. The way out is a single call, "".join(pieces).
Reassigning is not modifying
One objection occurs to everyone here: if strings and integers never change, how can a variable change value on every line? Because a name, in Python, is not a box holding a value: it is a label stuck on an object. Reassigning it means peeling the label off and sticking it elsewhere, not repainting the object.
The image can be checked in two lines. The id function returns a number unique to each living object, a kind of address in memory.
counter = 1
print(id(counter))
counter = counter + 1
print(id(counter)) # a different identifier, so a different objectThe number is no longer the same: the integer 1 was not increased, Python built an integer 2 and moved the counter label onto it. That is the difference the is operator measures, since it answers about the object where == answers about the value.
The two camps
Knowing which side a type falls on is not a purist's curiosity: the question comes back whenever a value is filed into a dictionary or whenever the same data is shared by two parts of the code. Here is where the built-in types fall.
| Type | Camp |
|---|---|
| Integers, floating point numbers, booleans | Immutable |
| str and bytes | Immutable |
| tuple and frozenset | Immutable |
| None | Immutable |
| list, dictionary, set | mutable |
Memorising that table is not necessary: the code tells you on its own. A method that hands a result back works on an immutable object, for want of any other way of returning its work. A method that returns nothing, such as append or sort, has modified the object in place, so that object is mutable.
What immutability makes possible
A rule this strict looks like a whim until you have seen what it pays for. An object that never changes can be summarised by a stable fingerprint, which Python calls a hash, and that fingerprint acts as a filing address inside a dictionary.
positions = {(0, 0): "start", (3, 4): "finish"}
positions[[0, 0]] = "elsewhere"
# TypeError: unhashable type: 'list'The refusal is not a punishment. If the list changed after being filed away, its fingerprint would change with it and the dictionary would then look for the key where it no longer sits. The TypeError therefore lands at the door.
The second benefit concerns default values. A default value is built once, when Python reads the definition, and not on every call: immutable, that is never noticeable; mutable, it becomes noticeable in the worst way.
An empty list as a default value, as in def add(item, basket=[]):, is shared by every call: the second customer's basket already holds the first customer's items. Write basket=None, then create the list inside the function.
The third benefit counts the day a program grows: an immutable object travels between several functions, modules or threads with no defensive copy and no lock, since nobody can modify it behind the back of its creator.
Immutability stops at the first level
One last point catches out those who think the rule is settled. A tuple guarantees that its slots will not change contents, but nothing about the objects filed inside them: if one of those slots holds a list, that list stays modifiable.
config = ("prod", ["a", "b"])
config[1].append("c")
print(config) # ('prod', ['a', 'b', 'c'])
config[1] = []
# TypeError: 'tuple' object does not support item assignmentThe tuple refuses to have a slot replaced, and says nothing about what already sits inside it. The consequence catches almost everyone once: a tuple holding a list is not hashable and gets turned away as a dictionary key.
The fingerprint is computed from the contents, not from the label on the container. A complete guarantee therefore requires contents that are immutable all the way down: a tuple of tuples, a tuple of strings, or a frozenset.
Frequently asked questions
Why is my string unchanged after a replace?
Because replace builds a new string and hands it back, without ever touching the original: keeping the result means writing text = text.replace(...). Every string method behaves that way.
How can objects of my own classes be made immutable?
The simplest route is a dataclass declared with frozen=True, or a namedtuple for a few named fields. Any attribute assignment after creation then raises an error. The first level rule still holds: freezing the class does not freeze a list filed inside one of its fields.
Does immutability make a program faster?
Rarely in any spectacular way, even though Python internally reuses certain small integers and short strings. The real gain lies elsewhere: an object that cannot change removes an entire family of bugs, the ones where a value ends up modified at a distance. The time saved is saved while debugging.