Embercode
✕
References and MemoryLesson 14 of 15

35 min read

Read-only values in Python

Why it matters

A program often breaks because a value changed when nobody expected it to. A function receives a list, edits it, and the caller now has a different list than before. A setting is overwritten halfway through a run. These bugs are hard to find because the line that caused them is far from the line that shows the wrong result.

Many languages let you mark a value as read-only, so the checker refuses any line that tries to change it. Some call this const or final. Python has no keyword with the same force. It has conventions, a type hint that a checker can read, and several standard types that really cannot be changed. This lesson shows what each option locks, and where Python leaves the choice to you.

By the end of this lesson you will be able to make a value that cannot be changed in place, make an object whose fields cannot be reassigned, give a caller a read-only view of a dictionary, and explain why none of these stops a list inside an object from growing.

The earlier lessons in this module covered what a reference is. This lesson assumes you know that a name points at an object, and that the object is what can be changed in place.

The idea: a lock can sit on the name or on the contents

Think of a name as a labelled box, and the object as the thing in the box. When you lock a value, you can lock two different things:

  • The name, so nobody can point it at a different object.
  • The object, so nobody can change what is inside it.

Some languages let you choose which lock to use, and some can lock both at once. Python has no keyword for either lock. Its usual approach is different: the type of the object decides whether it can change. A tuple cannot change, a list can, and a class can be made to refuse changes to its fields. That single difference explains most of the surprises in this lesson.

In Python, an immutable object is one that has no method to change it in place. A name can always be moved to another object.

Constants by convention

Python has no keyword that stops a name from being reassigned. By convention, a name written in capital letters, such as LIMIT, is a constant. The interpreter does not enforce this, so the rule depends on the people who read the code.

Python · editable

Type nothing into the Input box for this program.

LIMIT is 3
LIMIT is now 4

The second assignment runs without an error. The capital letters tell a reader that LIMIT should not change, and a linter (a tool that reads code and flags likely mistakes) or a type checker can warn about a second assignment. The interpreter itself allows it.

The type hint typing.Final lets a checker such as mypy, a type checker, report a reassignment. The interpreter still does not enforce it when the program runs:

Python · editable

Type nothing into the Input box for this program.

RATE is now 0.7

So Final is a promise to the reader and to the checker, not a lock. For a name, Python offers a convention and a checker, but no rule that the runtime enforces.

Tuples and frozensets: values that cannot change

A tuple is a fixed group of values. Once it is made, you cannot change its items in place. A frozenset is a set that cannot change either. Their items are fixed, so a function that receives one knows that it cannot change the caller's data.

Python · editable

Type nothing into the Input box for this program.

TypeError: 'tuple' object does not support item assignment
(1, 2, 3) (1, 2)
frozenset({1, 2}) True
AttributeError: 'frozenset' object has no attribute 'add'

The line t[0] = 9 fails because a tuple has no item assignment. The expression t + (3,) makes a new tuple, and t is unchanged. A frozenset has no add method, so the attempt raises an AttributeError. The duplicate 2 in the list is kept once, because a set holds each value only once.

Use a tuple where a list would be a list of fixed values, such as a point (x, y) or a row of data. Use a frozenset where the values are a fixed collection that you only check membership in. A function that takes a tuple as its parameter promises that it does not change the caller's data, although only the type makes that promise, not a convention.

Python · editable

Type nothing into the Input box for this program.

15

The call tuple(nums) makes a copy of the list as a tuple, so the function receives a value that it cannot change. This is the Python version of a read-only input.

Frozen dataclasses: a record whose fields cannot change

A dataclass is a class that holds a few values, and the dataclass decorator, a line written above a class that changes how it works, writes the constructor (the code that builds a new object) and the printed form for you. Adding frozen=True makes each field read-only after the object is created. Assigning a field raises an error.

Python · editable

Type nothing into the Input box for this program.

Point(x=3, y=4)
FrozenInstanceError: cannot assign to field 'x'

A frozen dataclass gets a hash value, a number computed from its fields, so it can go in a set or be a dictionary key, as long as its fields have hash values too. A field that holds a list is a different case, as the next example shows.

Python · editable

Type nothing into the Input box for this program.

FrozenInstanceError: cannot assign to field 'name'
Config(name='dark', tags=['beta', 'x'])

The field name cannot be reassigned, because frozen=True locks the field. The field tags holds a list, and the list itself is not frozen. The call cfg.tags.append("x") works, because the object locks its fields, not the objects inside them.

A frozen field locks the name in the field, not the object it refers to. To make the whole value read-only, every field must hold something that cannot change, such as a tuple, a string or a number. Put the list into a tuple when the object is created:

Python · editable

Type nothing into the Input box for this program.

AttributeError: 'tuple' object has no attribute 'append'
Config(name='dark', tags=('beta',))

The tuple(...) call copies the caller's list when the object is built, so later changes to the caller's list do not reach the object. Copying on the way in is the defensive copy that the next lesson on aliasing explains.

Read-only views of a dictionary

Sometimes a caller should be able to look at a dictionary without changing it, and the original should still be the one the program updates. The types.MappingProxyType class gives a read-only view. The view shows the current contents of the dictionary, so a change to the original shows up in the view, but the view itself refuses any write.

Python · editable

Type nothing into the Input box for this program.

2 2
TypeError: 'mappingproxy' object does not support item assignment

The view is a window onto the dictionary, not a copy. Use it to return internal data from a class without giving the caller a way to change it. If the caller should keep a snapshot instead, make a copy with dict(d).

Read-only memory

A memoryview can be made read-only with toreadonly(). The view reads the bytes of an object without copying them, and it refuses writes.

Python · editable

Type nothing into the Input box for this program.

97
TypeError: cannot modify read-only memory

The byte a has the code 97. The view can read it, but it cannot write to it. The original bytearray can still change, and the view shows the change, as the dictionary view does.

Enums for a fixed set of named values

When a program needs a fixed set of named values, such as sizes or states, an enum (a class with a fixed list of named values) is clearer than a set of constants. Each member is one object, and Python refuses to reassign a member after the class is defined.

Python · editable

Type nothing into the Input box for this program.

Size.MEDIUM
2

The members are fixed, and you refer to them by name. A typo such as Size.MEDUM raises an AttributeError at once, which is better than a silently wrong number.

Worked examples

Example 1: a read-only limit in a loop

A common use of a constant is a value that controls a loop and should not change during it. By convention, the limit is named in capitals, and a checker reports a reassignment inside the loop.

Python · editable

Type nothing into the Input box for this program.

step 1 of 3
step 2 of 3
step 3 of 3

If a later edit writes LIMIT = 10 inside the loop, the interpreter accepts it. A type checker with Final written on the name would report the line before the program runs. The convention is weaker than a compiler check, so use a checker in the project if the rule matters.

Example 2: a configuration object built once

Suppose a program reads its settings from a file and then uses them in many places. If any part of the program can change the settings, bugs will follow. A frozen dataclass makes the settings read-only after they are created, as long as every field is an immutable value.

Every place that receives a Settings object can read its fields, and no place can change them. To change a setting, the program makes a new Settings object and passes that along instead. The method dataclasses.replace makes the new object with one field changed:

Python · editable

Type nothing into the Input box for this program.

Settings(theme='dark', retries=3)
Settings(theme='dark', retries=5)

The original object is unchanged, and the new one has the new value. Code that already has base cannot see a change made to changed, so the two can be used safely in different places.

Common mistakes

Mistake 1: thinking a frozen dataclass makes its contents read-only. The Config example showed that cfg.tags.append("x") works, because the field tags holds a list, and the list is not frozen. Use tuples, strings and numbers for the fields of a read-only object.

Mistake 2: thinking Final stops a change at run time. typing.Final is read by a checker before the program runs. The interpreter accepts the reassignment, as the RATE example showed.

Mistake 3: assuming a tuple holds read-only copies of its items. A tuple cannot change its own items, but an item that is a list can still change. t = ([1], 2) is a tuple whose first item is a list, and t[0].append(5) works.

Mistake 4: catching the error and ignoring it. A TypeError or an AttributeError from a read-only value means the code tried to change something it should not change. If the code silently continues, the program runs with a value that was never updated. Decide what the code should do in that case, instead of hiding the error.

Mistake 5: making a copy that is shallow. list(items) and dict(d) copy the top level only. A list of lists still shares the inner lists with the original. Lesson 4 covered the difference between a shallow and a deep copy.

Practice

Exercise 1. The code below has two lines that change something. Say which one fails, and why.

nums = (4, 5)
nums[1] = 8
nums = (4, 9)

Show the answer

The second line fails with TypeError: 'tuple' object does not support item assignment, because a tuple cannot change its items. The third line works, because it moves the name nums to a new tuple. The name can be moved, but the tuple cannot be changed in place.

Exercise 2. Which of these values can be changed in place: an int, a str, a tuple, a list, a frozenset, a dict?

Show the answer

An int, a str, a tuple and a frozenset cannot be changed in place. A list and a dict can be changed. A name that holds an immutable value can still be moved to a different value, so immutability is about the object, not the name.

Exercise 3. Given the class below, change it so that a caller cannot modify the list returned by items().

class Inventory:
    def __init__(self):
        self._items = []
 
    def items(self):
        return self._items

Show the answer

Return a copy, or a tuple, from the method:

class Inventory:
    def __init__(self):
        self._items = []
 
    def items(self):
        return tuple(self._items)

Now inv.items().append("x") raises an AttributeError, because a tuple has no append method. The attribute _items is still a list inside the class, so the class's own methods can still add to it. The leading underscore is a convention that tells callers not to use it directly.

Exercise 4. Given @dataclasses.dataclass(frozen=True) with a field tags: list, which of these lines work? Assume cfg is an instance of that class.

  1. cfg.name = "other"
  2. cfg.tags.append("x")

Show the answer

Line 1 raises FrozenInstanceError, because the field is frozen. Line 2 works, because frozen=True locks the field, not the list inside it. To stop line 2, the field must hold a tuple.

Exercise 5. A class stores a dictionary and returns it from a method. The caller should see the current contents and must not change them. Which of these two options is right, and why?

  1. Return the dictionary itself.
  2. Return types.MappingProxyType(self._data).

Show the answer

The second option is right. The first option lets the caller change the dictionary. The second option is a read-only view that shows the current contents, so the caller cannot write to it. The class's own methods still update the dictionary, and the caller sees the changes. If the caller should keep a snapshot instead, return dict(self._data), which is a copy.

Summary

  • Python has no keyword that makes a name read-only. A capitalised name is a convention, and typing.Final is a promise for a checker.
  • An immutable object, such as a tuple, a frozenset, a string or a number, cannot be changed in place. Changing a name moves it to another object.
  • A frozen dataclass locks its fields against reassignment. A field that holds a list can still change, so use immutable values for the fields that must not change.
  • A types.MappingProxyType gives a read-only view of a dictionary, and a memoryview made with toreadonly() gives a read-only view of raw bytes.
  • A copy with tuple(...), dict(...) or list(...) protects the copy from changes made to the original, but only one level deep.
Check yourself: Stack and read-only values (7 questions) →Practice this topic: coding problems for References and Memory →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Recursion limits in Python