Why this matters
Suppose a bank account object has a balance. Two parts of a program hold the same account. One part adds money. The other part was in the middle of a calculation and now sees a different balance than it expected. Nobody wrote a bug in either part. The problem is that the object changed while another part was still using it.
An immutable object is one that cannot change after it is built. The word means "not changeable". Its attributes are set once, when it is created, and stay the same for its whole life. If you want a different value, you make a second object with the new value and leave the first one alone.
Immutable objects are easier to reason about. You can read a value, pass it to another function, or store it in a list, and you know it will still say the same thing later. Python already has many immutable types. Strings, numbers and tuples cannot be changed after they are made. This lesson shows how to build your own immutable objects with dataclasses and NamedTuple, and how to keep a list inside one safe.
This lesson shows one simple check in __post_init__. Checking input values in general is covered in the lesson on invariants and validation. It also does not cover how == and hashing work for these objects, which is in the next lesson.
A word you already use
Python strings are immutable. This is a good first example because you have probably used strings without thinking about it.
The output is:
s after upper: go
t: GOThe method upper does not change s. It builds a new string in capital letters and returns it. The line s.upper() throws that new string away, so s still holds go. To keep the result, you store it in a name, as the second part of the program does with t.
Beginners often expect s.upper() to change s. It does not. A method on an immutable object returns a new object instead.
Step 1: a class whose attributes are read-only
A plain class can be made immutable by setting each attribute once in __init__ and giving it only a read-only property. The encapsulation lesson showed this pattern. Here it is for a small counter.
The output is:
hits=0The class has no setter, so nothing outside the class can change _count. The method increment does not change the counter either. It returns a new Counter with the bigger number. The next sections show how to write this with less code.
If you try to change the count from outside, Python refuses:
The output is:
AttributeError: property 'count' of 'Counter' object has no setterStep 2: frozen dataclasses
Writing an initializer, a read-only property and a __str__ method for every field takes time, and the code is the same each time. The standard library has a decorator for this case, @dataclass. A decorator is a line starting with @ that changes the class written below it. A dataclass is a class whose main job is to hold a few values. You list the values as annotated attributes, and Python writes the initializer, a readable text form and an equality check for you. Adding frozen=True makes every attribute read-only.
The output is:
p = Point(x=3, y=4)
p.x = 3
equal? TrueThe single decorator gives you several things:
- An initializer that takes the two values in the same order,
Point(3, 4). - A readable text form,
Point(x=3, y=4), whichprintuses. - An
==check that compares the values, so two points with the same numbers are equal. - A rule that every attribute is read-only after the object is built.
The third bullet matters. A plain class compares two objects by identity. A dataclass compares them by value.
Assigning to an attribute now fails:
The output is:
FrozenInstanceError: cannot assign to field 'x'The error type is FrozenInstanceError. It is raised at run time, when the line runs, not when the code is written. A type checker such as mypy reports the same line before the program runs.
Copy on change with replace
An immutable object cannot be changed, so a program that needs a different value must build a new object. This is called copy on change. The old object stays the same, and a new object holds the changed value. The function replace from the dataclasses module makes that copy. You give it the object and the fields to change.
The output is:
p = Point(x=3, y=4) , moved = Point(x=13, y=4)The original point p still holds 3 and 4. The name moved points to a second object. The fields you do not name are copied from the original.
The same idea works for a counter that counts up. Each step returns a new counter, and the old one is unchanged:
The output is:
c1 = Counter(label='hits', count=0) , c2 = Counter(label='hits', count=2)Each call to increment builds a new object, so the chained call c1.increment().increment() creates two new objects in order. c1 is never touched.
Checking values with __post_init__
Often an immutable object needs a check before it is accepted. For example, a range from 2 to 9 is valid, but a range from 9 to 2 is not. A dataclass runs a method named __post_init__ right after its initializer sets the attributes. Raise an error there to refuse a bad value.
The output is:
r = Range(low=2, high=9) , length = 7
error: low (9) is greater than high (2)The try and except blocks catch the exception that __post_init__ raises. Without them, the program would stop with an error message. Once a Range is built, its values are known to be valid, and no other code can break that rule, because the attributes are read-only.
Step 3: a list inside an immutable object
A frozen dataclass protects its own attributes, but it does not protect the objects they point to. A list is changeable, so a list held by a frozen dataclass can still change from outside. Look at this example:
The output is:
team after outside change: Team(name='Blue', members=['Asha', 'Ravi'])The Team object did not change. Its members attribute still points to the same list. But the list itself is changed by names.append("Ravi"), and the team shows the change. The attribute is read-only, yet the team is not truly immutable, because a part of it can still change from outside. The fix is to store a tuple instead. A tuple cannot be changed after it is made.
The fix copies the list into a tuple when the object is built. A frozen dataclass cannot assign to its own attributes with normal syntax, so the copy uses object.__setattr__. This is the one place where that call is needed, inside __post_init__.
The output is:
safe team after outside change: SafeTeam(name='Blue', members=('Asha',))
AttributeError: 'tuple' object has no attribute 'append'After tuple(self.members), the object holds its own copy. Adding Ravi to the original names list does not affect the team. A caller who tries to add to safe.members gets an AttributeError, because a tuple has no append method.
The copy is shallow. It copies the list, not the items inside it. Strings cannot change, so this works. If the list held mutable objects of your own, those objects would still need their own protection.
Step 4: named tuples
A named tuple is a tuple with names for its positions. It is immutable, and it has a readable text form and an equality check, like a frozen dataclass. It is written with typing.NamedTuple and a class body. It is the choice when the object is just a fixed set of values and needs no methods beyond a few small ones.
The output is:
Money(cents=1050) Money(cents=1300)
AttributeError: can't set attributeThe method plus builds a new object, so a keeps 1050. Assigning to a.cents fails. A named tuple can also be unpacked, so cents, = a works, which a dataclass does not offer.
Use a frozen dataclass when you want a default value, a __post_init__ check or a method that is easy to read. Use a named tuple when you want the smallest possible object.
Python has no const, and it has no copy by default
Some languages have a const keyword that marks a value, a parameter or a method as read-only. C++ is one of them, and it also copies an object when you assign it with =. Python has neither.
In Python a name is a label that points to an object. Assigning one name to another does not copy the object. Both names point to one object, and a change through one name shows through the other. This is the same behaviour as Java references.
The output is:
shared: [1, 2, 3] True
copy: [1, 2, 3] [1, 2, 3, 4]The line b = a gives one list two names. The function copy.copy makes a new list with the same items, so changing c does not change a. For a list of lists, use copy.deepcopy to copy the inner lists too.
Python has a type hint called Final that tells a type checker a name should not be reassigned. It does not stop the change at run time, and it does not make the object read-only. For an object that must not change, use a frozen dataclass, a named tuple, or a tuple.
Common mistakes
These mistakes are very common. Each one below shows the real output you will see.
Thinking a read-only attribute makes the whole object immutable
The Team example above has a frozen dataclass with a list field. Adding a name to the list outside the object still changes the team. The attribute is fixed, but the list it points to is not. Copy the list into a tuple in __post_init__, or use a type that cannot be changed.
Assigning to a field of a frozen object
A frozen dataclass refuses the assignment with FrozenInstanceError, as the earlier example showed. Use replace(p, x=5) to make a new point with the new x value.
Calling append on a tuple
A tuple has no append method. The call fails at the line that makes it, and the error names the type:
The output is:
AttributeError: 'tuple' object has no attribute 'append'The message names the type, tuple, and the missing method. To build a longer sequence, create a new tuple, such as nums + (3,), or convert to a list, change it, and convert back.
Expecting a change to a string to show up everywhere
A string is immutable, so a name that holds a string keeps its old text until you assign a new string to it. The next example shows the trap.
The output is:
Asha ashaThe line name = name.title() attaches the name name to a new string. The name alias still points to the old string, which is still asha. Assigning a new value to a name never changes the object that another name points to.
Debugging walkthrough: a value that changed when it should not have
Suppose an object's value changed in a place that should not change it. Use this routine.
- Find where the value is stored. Is it an attribute of a frozen dataclass, a named tuple, a tuple, or a plain object?
- If it is a list or a dictionary inside the object, search the whole program for the other names that point to it. Look for assignments such as
b = a, or for code that stores the same list in two places. - Make a copy in the constructor or in
__post_init__, usingtuple(...)for a list anddict(...)for a dictionary, so the object owns its own data. - Run the program again. If the value still changes, the change happens somewhere else that writes to the same object, and the search in step 2 continues.
Variations and edge cases
A frozen dataclass compares by value. Two frozen dataclass objects with the same fields are equal, and Python can use them as keys in a dictionary or as items in a set. Those uses need a hash, a number Python computes from the object's values. Calling hash() on a frozen dataclass with a list field raises TypeError, because a list cannot be hashed. This is one more reason to use tuples inside immutable objects.
A dataclass can give a default to a field with field(default=...). Use field(default_factory=list) when the default must be a new list for each object. A plain default such as items: list = [] raises a ValueError when the class is defined, because one list would be shared by all objects. It is the same trap as a mutable default in a normal function.
An immutable object does not have to store every value it needs. A Range can compute its length each time it is asked, as the example did. Compute values that depend on other values, and store only the source values.
Edge case: a frozen dataclass can still call a method that writes to a mutable object inside it. The rule protects the attributes, so a method that appends to a list field still works. Use tuples or copies to prevent that.
Interview-style questions
What is an immutable object? An object whose value cannot change after it is created. A change is made by building a new object.
How do you make a dataclass immutable? Use @dataclass(frozen=True). Assigning to an attribute then raises FrozenInstanceError.
Does a frozen dataclass make a list inside it immutable? No. The attribute is fixed, but the list it points to can still change. Store a tuple, or copy the list into a tuple in __post_init__.
What is the difference between == and is for two dataclass objects? == compares the values, so two equal points give True. is checks whether the two names point to the same object, which is usually False for two separately built points.
Does Python copy an object on assignment? No. b = a attaches a second name to the same object. Use copy.copy or copy.deepcopy to make a copy.
Why use replace instead of a setter? replace builds a new object with one value changed, and the old object stays the same. Other code that holds the old object keeps seeing the old value.
Real-world uses
Configuration objects are often immutable. A program reads its settings once, then passes them to every part that needs them. Any part can read the settings, and none can change them by accident. Money amounts, dates and coordinates are often immutable too, because a change should always create a new value that can be checked.
Shared data is the other main use. A cache, a list of product prices or a set of permissions can be handed to many threads or functions at once. If nothing can change it, no lock is needed to read it.
Practice
Exercise 1
Write a dataclass Money with one field, cents, an integer. Its __post_init__ must raise a ValueError when cents is negative. Add a method plus that takes another Money and returns a new Money with the sum.
Show the answer
The output is Money(cents=1050) Money(cents=1300). The method plus builds a new object, so a keeps 1050.
Exercise 2
The class below is not fully immutable. Explain why, and show the change that fixes it.
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
id: str
items: listShow the answer
The attributes are read-only, so id cannot change. The items list can still be changed by any code that holds the list, because the object stores the list it was given. The fix is to copy it into a tuple in __post_init__:
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
id: str
items: tuple
def __post_init__(self):
object.__setattr__(self, "items", tuple(self.items))Exercise 3
Predict the output of this program, then run it to check.
s = "go"
t = s.upper()
print(s, t)Show the answer
The output is go GO. The method upper returns a new string in capital letters. The name s still holds the original text.
Exercise 4
Write a class Temperature with a method warmer(degrees) that returns a new Temperature with the value increased by degrees. The object holds one value, celsius, which must not change after it is built. Make the object with a frozen dataclass.
Show the answer
The output is 20 25. The method warmer does not change t. The frozen dataclass makes the attribute read-only, so the rule is enforced by Python, not only by the method.
Exercise 5
Write a named tuple Range with fields low and high. Add a method shift_by(amount) that returns a new Range with both values moved by amount. Do not change the original.
Show the answer
The output is Range(low=2, high=9) Range(low=12, high=19).
Summary
- An immutable object cannot change after it is built. A method that seems to change it returns a new object instead.
- A frozen dataclass gives you read-only attributes, a readable text form and value equality in one decorator.
replacebuilds a copy of a frozen dataclass with one value changed.- A read-only attribute does not protect a list it points to. Store a tuple, or copy the list into a tuple in
__post_init__. - A named tuple is the smallest immutable choice. Assignment to its fields raises an error.
- Python has no
constand assignment does not copy. Usecopy.copyorcopy.deepcopywhen you need a separate object.