Embercode
✕
Functions and ScopeLesson 15 of 17

40 min read

Read-only parameters in Python

Why this matters

Suppose a function receives a list of ten thousand student names and must count how many names are longer than five letters. Python does not copy all ten thousand names before the function starts. It passes the list itself, so the cost of the call stays small no matter how long the list is.

That raises a new problem. If the function gets the real list and not a copy, it can change the caller's list by accident. Some languages have a keyword that blocks one kind of accident. Python does not have that keyword. Many beginners expect Python to protect the list anyway. It does not. This lesson shows what Python does when a function receives a value, what it leaves open, and how to get a list or a dictionary that the function cannot change.

This lesson assumes the idea of parameters and return values from the earlier lesson in this module.

Words used in this lesson

A parameter is a name in a function's header. In def count_long(words):, words is a parameter. It receives the value that the caller passed in.

An object is a thing that Python keeps in memory while the program runs. Lists, dictionaries, strings and numbers are all objects.

A reference is a value that tells Python where an object lives in memory. Think of a street address. Copying an address takes very little work. Copying the house takes much longer. In Python, a variable holds a reference to its object.

A name is a word that points to an object, such as words or total. Assigning a value with = makes the name point to that object. It does not change the object.

An immutable object cannot be changed after it is made. Numbers, strings and tuples are immutable. A mutable object can be changed in place. Lists and dictionaries are mutable.

A tuple is a sequence like a list, written with round brackets, that cannot change after it is made. ("cat", "owl") is a tuple.

A view is an object that shows the data of another object without holding its own copy. A copy is a new object with its own data.

Passing a reference, not the object

When Python calls a function, it passes the reference that the caller gave. For a number, the reference points to the number. For a list, the reference points to the list. The function's parameter and the caller's variable then point to the same object.

Here is a program that shows this. Run it and read the output before you read the explanation.

Python · editable

Output:

count of long words: 2
same object inside function: True

The function count_long reads every name in the list and counts the names with more than five letters. The words "banana" (six letters) and "elephant" (eight letters) are the two long names, so the count is 2.

The function same_object uses the operator is. The is operator asks whether two names point to the same object. We passed words twice, so both parameters point to one list, and the answer is True. Python did not copy the list when it passed it to count_long. The function worked on the same list the caller made.

The same rule applies to any list, including a list of a million numbers. The function gets a reference, not a copy of the numbers.

Giving a parameter a new value

The first question is what happens when a function gives its parameter a new value. The answer is that the caller does not change. Here is a function that replaces its parameter.

Python · editable

Output:

['new']
['old']

The line items = ["new"] makes the name items point to a new list inside the function. The caller's name old still points to the original list. When the function ends, its name items is gone, and the original list is unchanged.

Python does not stop this assignment with an error. A function is free to give its parameter a new value. Some languages have a keyword that makes a parameter read-only, so that such an assignment is a compile error. Python has no such keyword. The word final is an ordinary name here, and def f(final): return final * 2 runs as normal code.

If you want a name to be read-only in the sense of "must not be given a new value", you can use the type hint Final from the typing module on a variable. A type hint is a note about what a value should be. Python does not check it while the program runs. A type checker, such as mypy or pyright, is a separate tool that reads the code and reports the mistakes. Here is the difference.

Python · editable

Output:

20

The program runs and prints 20, because Python does not enforce Final at run time. A type checker reports the second assignment as an error before the program runs. The program itself does not stop.

Changing the object is still possible

The most common mistake is to think that a parameter protects the list it points to. It does not. A function can change the list through its parameter, and the caller sees the change.

Python · editable

Output:

after add_tag: ['apple', 'banana', 'seen']

The function add_tag calls append on the list it received. That changes the one list that words also points to. The caller sees "seen" in its own list. Giving a parameter a new value does not change the caller, but changing the object does.

If you want the function to be unable to change the list, you need one of the options in the next section.

Read-only views and copies

Python offers a few ways to give a function data that it cannot change through its own name. Each one has a cost and a limit.

A tuple is the simplest. A tuple cannot be changed at all. When you build a tuple from a list with tuple(words), Python makes a new object with the same items. That is a copy. The copy costs time and memory, in proportion to the number of items. Later changes to the list do not reach the tuple.

For a dictionary, the standard library has a view. types.MappingProxyType wraps a dictionary and does not copy it. The view refuses changes made through its own name. But the view shows the original dictionary, so when the owner changes the dictionary, the view shows the change too.

The standard library has no view for lists. For a list, the usual choices are a tuple, which is a copy, or a list copy made with list(words) or words[:]. A list copy can still be changed, so it does not block changes.

Here is a program that shows a tuple and a list copy.

Python · editable

Output:

snapshot: ('apple', 'banana', 'cat', 'elephant')
words: ['apple', 'banana', 'cat', 'elephant', 'late']

The tuple kept the four original words, because it was made before the call to append. The list words shows the new word, because the owner changed it.

Here is the same idea for a dictionary, with a view.

Python · editable

Output:

view sees the change: {'a': 1, 'b': 2}
TypeError: 'mappingproxy' object does not support item assignment

A try block runs code that might raise an error, and except catches that error so the program can go on. The view shows "b" because owner["b"] = 2 changed the one dictionary that both names point to. The assignment through the view failed, because the view does not allow changes through its own name.

The table compares the choices. The columns say whether the choice copies data, whether the owner's later changes show up, and whether it blocks changes through this name.

ChoiceCopies the dataOwner's later changes show upBlocks changes through this name
Plain parameterNoYesNo. A new value for the name is allowed, and changes to the object show up
tuple(words)YesNoYes, the tuple cannot change at all
MappingProxyType(d) for a dictionaryNoYesYes, for item assignment
list(words) or words[:]YesNoNo. The copy is a list and can change

Choose the view when the function only reads the data during its own call and the owner will not change the data while the function runs. Choose the copy when the function must keep the data after it returns, for example when it stores the data for later, or when the owner might change the data while the function is still working.

Lists have no read-only form, so copy them

Python lists can always change. A function that must not change a list parameter has two options. It can work on a copy, or it can ask for a tuple instead of a list. The next example shows why this matters.

A second example: sorting changes the caller's list

Suppose you write a function that finds the median of some readings. The median is the middle value after the numbers are put in order. A simple version sorts the list and then reads the middle value.

Python · editable

Output:

median: 5
readings after median: [2, 4, 5, 7, 9]
safe median: 5
more after safe median: [9, 2, 7, 4, 5]

The first version returns the right answer, 5. But the caller's list is now in sorted order, which the caller did not ask for. The method values.sort() changes the list in place, so the caller sees the change. Python does not prevent this.

The second version calls sorted(values). That function builds a new list with the same numbers in order, and the caller's list stays as it was. The new list costs memory, in proportion to the length of the list. The cost is worth paying when the caller would be surprised by a change. When the function only reads the data, you can avoid the copy.

Writing your own read-only functions

Here is a function that reads a list of numbers and returns their total. It does not change the list, because it only reads each value once.

Python · editable

Output:

total: 6
list after total: [3, 1, 2]

The function reads the list and returns one number. The list stays the same. Nothing stops the function from changing the list, so a reader has to check the body to know that it only reads. The name total and a short body make that easy to see.

Here is a function that returns a sorted copy and leaves the input alone.

Python · editable

Output:

sorted copy: [3, 5, 8]
original: [5, 3, 8]

The function returns a new list, so the caller can keep both. The original stays in its order because sorted never changed it.

A third example: a tuple that the function cannot change

A tuple is useful when a function should read a list that belongs to someone else. The function below counts short words, but it also adds an extra word to the list, which a function that only reads should not do. The first call passes a tuple. The second call passes the real list.

Python · editable

Output:

method tried to change the tuple: 'tuple' object has no attribute 'append'
owner after the call: ['cat', 'horse', 'owl']
count on owner: 2
owner after second call: ['cat', 'horse', 'owl', 'extra']

In the first call, the function reached words.append("extra"). A tuple has no append method, so Python raised an AttributeError before the loop ran. The try block caught it, and the owner's list still has the same three words.

In the second call, the function received the real list, so append worked and the owner's list now has the extra word. The count is 2, because only "cat" and "owl" have three letters or fewer.

A tuple stops every change through its name. Because tuple(owner) made a copy, the owner's later changes do not reach the tuple at all.

Common mistakes

Thinking a parameter makes the list read-only. This mistake shows no error. The program runs and the list changes. The output after add_tag: ['apple', 'banana', 'seen'] from the example above is the sign. If you need a list that cannot change, pass a tuple or a copy.

Expecting a number to change through its parameter. A number is immutable, so a function cannot change it. Here is a function that tries to reset its argument.

Python · editable

Output:

5

The function changed its own name x, not the caller's value. To use the new number, the caller must store the return value, as in value = reset(value).

Assuming that a tuple is a view. tuple(words) makes a copy. When the owner adds an item to the list, the tuple does not show it. Use types.MappingProxyType for a dictionary view, and accept that a list has no view in the standard library.

Calling append on a tuple. A tuple cannot change, so append is not a method it has. The error is AttributeError: 'tuple' object has no attribute 'append'. Read the last part of the message. It names the type and the missing method.

Sorting or changing a list parameter. values.sort() and values.append() change the caller's list. The fix is to copy the list first, as in sorted_copy, or to use a function that builds a new list.

Using a list as a default argument. A default argument is a value a parameter gets when the caller does not pass one. Python creates a default value once, when the def line runs, and reuses it for every call. A default list is shared between calls, so it keeps growing.

Python · editable

Output:

['a']
['a', 'b']
['a']
['b']

The second call to add_item shows 'a' from the first call, because both calls used the same default list. The fix is to use None as the default and create the list inside the function. The second function gives each call its own list.

Practice

Exercise 1. What does this function print, and why?

def reset(x):
    x = 0
 
value = 5
reset(value)
print(value)

Show the answer

The program prints 5. The function gives its own name x a new value. The caller's variable value still points to the number 5. Python does not stop the assignment, so there is no error. To change the caller's value, the function must return the new value and the caller must store it.

Exercise 2. A function has this definition: def add_one(nums): with the body nums.append(1). The caller has the list [7], calls add_one with it, and prints the list. What does it print?

Show the answer

The program prints [7, 1]. The name nums points to the caller's list, and append changes that list. The caller sees the new item at the end.

Exercise 3. Write a function total that returns the sum of a list of numbers and does not change the list. Test it on the list [3, 1, 2].

Show the answer

Python · editable

The output is 6, then [3, 1, 2]. The function only reads the list, so the list is unchanged afterwards.

Exercise 4. Write a function sorted_copy that returns the values in order without changing the input list. Test it on [5, 3, 8].

Show the answer

Python · editable

The output is [3, 5, 8], then [5, 3, 8]. The function returns a new list, and the input list keeps its order.

Summary

  • A function receives a reference to the caller's object, so no copy is made when the function is called.
  • Giving a parameter a new value does not change the caller, but changing the object through the parameter does.
  • Python has no final keyword for parameters. A Final type hint is checked by a type checker, not when the program runs.
  • A tuple blocks changes through its name, and tuple(words) makes a copy that does not follow later changes to the list.
  • Use sorted to get a sorted copy, because list.sort changes the list in place.
  • Do not use a list as a default argument. Use None and create the list inside the function.
Practice this topic: coding problems for Functions and Scope →

Stuck? Ask the tutor

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

Sign in to ask the tutor.

← Refactoring a long Python script