Why this matters
A program often builds a list of values and then hands it to another part of the code. A function returns the list of users in a group. A settings screen reads the list of allowed file types. A test checks the list of steps a process ran. If the receiving code can change the list, the original owner no longer knows what the list contains. A bug in one place then shows up somewhere else, far from the cause.
An immutable collection is one that cannot be changed after it is created. You can read it, count it and search it, but you cannot add, remove or replace an item. Using an immutable collection is a simple way to say "nobody may change this" and to make the runtime enforce that rule.
Python has immutable versions of the main containers. A tuple is an immutable sequence, a frozenset is an immutable set, and MappingProxyType gives a read-only view of a dict. Python does not have an immutable list, and the protection has limits that the last sections explain.
The lesson on changing a collection during a loop explained how a loop can break when a collection changes under it. This lesson covers how to stop a collection from changing at all.
The idea: a sealed jar and an open box
Think of two containers for jam. An open box lets you put jam in and take jam out. A sealed jar is already full and closed. You can open it and look at the jam, and you can pour some into a bowl, but you cannot put a new spoonful into the jar.
A list is the open box. A tuple is the sealed jar. When you need a sequence of names and only read it, the jar is the right choice. The people who receive the jar can look inside and eat from it, but they cannot put jam back or swap it for another flavour.
In code, the two versions look almost the same:
open_box = ["red", "green"]
open_box.append("blue") # works: the box is open
sealed = ("red", "green")
sealed.append("blue") # fails: the jar is sealedThe first line adds an item and the program carries on. The second line stops the program with an error. The next sections explain that error and the rules behind it.
A simple test helps when you design a function. Ask whether the code that receives the collection needs to change it. If it does not, give it an immutable collection, so a later change by mistake fails loudly instead of silently.
Tuples and frozensets
A tuple is written with round brackets, and a frozenset is made with the built-in frozenset function. Both can be read, counted, searched and looped over, exactly like a list. The only things that do not work are the methods and assignments that change the collection.
The output is:
('red', 'green') 31
2 4 TrueReading works as usual. Indexing, len, in and the for loop all work on a tuple or a frozenset. The examples below try to change them.
Trying to change one
Here is what happens when the code tries to change a tuple or a frozenset. Each attempt is wrapped in a try block, so the program reports the error and keeps going:
The output is:
append: 'tuple' object has no attribute 'append'
item assignment: 'tuple' object does not support item assignment
delete: 'tuple' object doesn't support item deletion
add: 'frozenset' object has no attribute 'add'
after attempts: ('red', 'green')The tuple has no append method, so the first attempt raises AttributeError. The tuple does have item access, but it does not allow assignment, so the second attempt raises TypeError. The frozenset has no add method. The tuple is unchanged after the attempts. A failed change does not leave the collection half changed.
Read the message to find the cause. The text names the type, such as tuple or frozenset, and the operation that the type does not support. The fix is to build a list, change it, and convert the result back with tuple(...) or frozenset(...).
Nulls and duplicates are allowed
Some languages refuse null or duplicate keys in an immutable collection. Python's tuples and frozensets accept both. None is an ordinary item, and a repeated item is simply stored once in a set.
The output is:
('a', None) True
2
{'a': 2}The dict literal {"a": 1, "a": 2} does not raise an error. The second value replaces the first, so the dict keeps {"a": 2}. If a repeated key is a bug in your data, check for it yourself before building the dict.
Read-only views of a dict
Python has no immutable dict type, but MappingProxyType from the types module gives a read-only view of an existing dict. A view is a second name for the same data, so it does not copy anything. If the original dict changes, the view shows the change, but the view itself refuses to be changed.
The output is:
view: {'a': 1, 'b': 2}
assignment: 'mappingproxy' object does not support item assignmentThe first line shows the view picked up the new key b, because it is a view, not a copy. A read-only view protects the view from changes, but it does not protect the original dict from changes made through its own name.
Copies and views
Python has two ways to hand out data that callers cannot change, and they behave differently. Choosing the wrong one is a common source of bugs. The table compares them.
| Option | Makes a new object? | Changes to the original show up? |
|---|---|---|
tuple(items) | Yes | No |
frozenset(items) | Yes | No |
dict(view) | Yes | No |
MappingProxyType(d) | No, it wraps the dict | Yes |
The next program shows the difference between a view and a copy for a dict:
The output is:
view: {'a': 1, 'b': 2}
snapshot: {'a': 1}The view shows the new key, and the snapshot still shows the one key it had when it was made. The difference matters when a function returns data. If it returns MappingProxyType(internal), the caller sees every later change to the internal dict. If it returns tuple(internal) or dict(internal), the caller gets a fixed snapshot. Use the copy when the caller should not see later changes, and use the view only when that live link is what you want.
The same rule applies to lists. Python has no read-only view type for lists, so return a tuple made with tuple(items) when the caller should only read the data.
A class that keeps its own copy
The same idea matters inside a class. Suppose a Playlist object stores a list of songs. If the constructor keeps the list the caller passed in, the caller can still change the playlist after it was created. The fix is to copy the list in the constructor, and to store the copy as a tuple. This copy is called a defensive copy, because it protects the object from changes made outside it:
The output is:
Ana ('Intro', 'Verse')
input still has 3 songs
playlist: 'tuple' object has no attribute 'append'The caller added "Outro" to its own list, and the playlist did not change. The playlist stores a tuple, so code outside the class cannot add to it either. Without tuple(songs) in the constructor, both of those would work, and the playlist would change from outside. The class is then harder to reason about, because its contents can change at places far from the class itself.
Frozen dataclasses and named tuples
A class can also be made immutable. The dataclass decorator with frozen=True makes every field read-only after the object is created. The lesson on custom keys covered this for hashing. It is useful here too, when a record should never change after it is made:
The output is:
FrozenInstanceError cannot assign to field 'x'A NamedTuple from the typing module is also immutable. It is a tuple with named fields, so each field is read by name and cannot be assigned:
The output is:
Pair(name='Ana', score=3) 3Where Python's protection stops
The protection in this lesson is strong for tuples, frozensets, frozen dataclasses and named tuples, but it has two limits.
The first limit is that a tuple protects only its own items. A tuple that holds a list can have that list changed, because the list inside is still mutable:
The output is:
nested: ([1, 9], 2)The tuple itself did not change, but the list inside it did. A tuple is immutable only when every item in it is immutable too. If a tuple must not change at all, put only numbers, strings, tuples or frozensets inside it.
The second limit is that typing.Final is a note for type checkers (tools that read type hints without running the code), not a runtime rule. A name marked Final cannot be reassigned, as far as a type checker is concerned, but Python does not stop the program from changing the object the name points to:
The output is:
Final list still changes at runtime: [1, 2, 3]The Final note helps a reader and a type checker such as mypy. It does not stop the append. If you need real protection, use a tuple.
Python also has no const keyword. The closest runtime tool is the choice of an immutable type, which is a rule about the object, not about the name. Two names that point to the same list can still change it, whatever the annotation says.
Common mistakes
Calling append on a tuple. The fix depends on the design. If the function needs to build a sequence, build a list inside it and return tuple(...) at the end.
Thinking a tuple is fully immutable. A tuple that holds a list can still have that list changed. Keep only immutable items inside a tuple that must not change.
Expecting a dict literal to refuse duplicate keys. The dict {"a": 1, "a": 2} keeps the last value and raises no error. Check for duplicates in your data before you build the dict.
Returning a view when a snapshot is needed. The caller then sees every later change to the original. Return dict(...) or tuple(...) for a snapshot.
Expecting Final to stop changes at runtime. Final is a note for type checkers. Use a tuple or a frozen dataclass when the runtime must refuse changes.
Changing a string. Strings are immutable too, so text[0] = "x" raises TypeError: 'str' object does not support item assignment. Build a new string instead, such as "x" + text[1:].
Practice
Exercise 1. What does this Python code print? Does it raise an error?
xs = (1, 2, 3)
xs[0] = 5
print(xs)Show the answer: it raises TypeError: 'tuple' object does not support item assignment on the second line. Nothing is printed, because the error stops the program before the print.
Exercise 2. Write a function clean_names(names). It should skip blank names, strip spaces from the others, and return an immutable result.
Show the answer:
def clean_names(names):
kept = []
for s in names:
if s.strip():
kept.append(s.strip())
return tuple(kept)With the input ["Ana", " ", "Ben ", ""], the function returns ('Ana', 'Ben'). A later append on that result raises AttributeError.
Exercise 3. What does this Python code print?
m = {"a": 1, "a": 2}
print(m)Show the answer: it prints {'a': 2} and raises no error. The second value replaces the first, because a dict has one entry for each key. A dict literal does not check for duplicates.
Exercise 4. Write a function total(values) that returns the sum of a sequence. Then explain why passing a tuple protects the caller's data.
Show the answer:
def total(values):
return sum(values)With (1, 2, 3) the function returns 6, and so does the same function with [1, 2, 3]. The function only reads the sequence. If the caller passes a tuple, any attempt inside the function to change it, such as values.append(4), raises AttributeError. The caller's data stays the same, as long as the items inside the tuple are immutable too.
Exercise 5. Write a function price(prices, item) that returns the price or -1 when the item is missing. Use get, not [].
Show the answer:
def price(prices, item):
return prices.get(item, -1)With {"coffee": 120, "juice": 80}, price(prices, "coffee") returns 120 and price(prices, "tea") returns -1. The get method returns the default when the key is missing, so it does not raise KeyError.
Summary
- An immutable collection cannot be changed after it is created. Reading, counting and searching still work.
- A tuple is an immutable sequence, and a frozenset is an immutable set. Changing one raises
AttributeErrororTypeError. MappingProxyTypegives a read-only view of a dict. A view shows later changes to the original, and adict(...)copy does not.- Return
tuple(...)ordict(...)for a snapshot, and store a tuple in a class when the caller must not change its contents. - A tuple protects only its own slots. Items inside it, such as lists, can still change.
Finalis a note for type checkers. It does not stop changes at runtime.