Why this matters
Suppose a function adds sales tax to a price. You test it with 100 and it returns 118.0. Tomorrow the same test returns 105.0, and you have changed nothing in the function. The problem is not in the arithmetic. The function reads a value from somewhere other than its inputs, and that value changed.
Programmers call a function like this impure. A function that depends only on its inputs is pure. A pure function is easier to test, easier to reuse and easier to reason about, because you can understand it by reading its parameters and its body. You do not need to know what else happened in the program before the call.
This lesson explains what makes a function pure, shows how to find the hidden state that breaks purity, and gives you ways to rewrite a function so the state is passed in instead. Earlier lessons in this module covered parameters and return values, which matter here because a pure function gets everything it needs through them.
The idea: output from inputs only
A function is a box with inputs on one side and an output on the other. In a pure function, the output depends only on the inputs. Give the same inputs, and you always get the same output, no matter how many times you call it, what order you call it in, or what the program did before.
Here is a small example that is pure:
Output:
5
5
5The function add reads only a and b. It does not print, does not change a variable outside itself, and does not read the time or a random number. So the output is the same every time for the same inputs.
A pure function has two rules.
- The result depends only on the parameters. No hidden value can change the answer.
- The function has no side effects. A side effect is any change to the world outside the function. Examples are changing a module-level variable, changing an object the caller passed in, printing to the screen, writing a file, or sending a message over the network.
A module-level variable is a name assigned at the top of a file, outside any function. A function can read it without any extra word. To assign to it, the function needs the word global, and that word is a sign that the function is impure.
When a function obeys both rules, you can replace any call with its answer and the program still means the same thing. That is the practical value of purity. Programmers say that a pure call can be swapped for its result.
Printing counts as a side effect. A function that prints a total is not pure, even if it also returns the total. The reason is that the printed text is a change to the outside world. In practice, many programs keep printing in the main part of the file and let the other functions compute answers. This is one of the simplest ways to make most of a program pure.
Example 1: the tax function with a hidden rate
Here is the function from the opening, written with a module-level variable for the tax rate. Any function in the file can read tax_rate, and any line of code can change it.
Output:
118.0
105.0The function has one parameter, price. Its answer also depends on tax_rate, which is not a parameter. Nothing in the call price_with_tax(100.0) says that the answer depends on a rate. The reader has to find the variable and see that it can change. This is hidden state, which is a value that affects a function's answer without appearing in its parameter list.
The fix is to make the rate a parameter:
Output:
118.0
105.0
118.0Now every input that matters is in the call. The line price_with_tax(100.0, 0.18) gives 118.0 wherever it appears in the program, and a test can check it with no setup. The function is pure.
The change has a cost. Callers must now pass the rate, which is one more value to remember. That cost is usually worth paying, because it makes the dependency visible. If many callers want the same rate, the code that loads the rate from a settings file can do so once and pass the value down. The rate is still held in one place, but now the place is in the main flow of the program, not hidden inside a function.
Example 2: a function that changes its input
Some functions change an object they receive. The most common case in Python is a list. A list is mutable, which means it can be changed after it is made. The method sort on a list rearranges that list in place. The list the caller holds is changed, and any other name that refers to the same list sees the change too.
The function sorted works differently. It builds and returns a new list, and the list it was given stays as it was. Here is an impure function and a pure one, side by side:
Output:
impure: a = [1, 2, 3], b = [1, 2, 3]
copy: c = [3, 1, 2], d = [1, 2, 3]In the first call, the caller's list a is now sorted. The caller wrote sort_impure(a) and expected a to stay the same, but it did not. The name b refers to the same list as a, so printing both shows the same sorted list. Nothing in the call line says that a will change. The name sort_impure is a hint, but the call looks the same as a call to a pure function.
In the second call, sorted_copy builds a new list with the same values in sorted order, and returns it. The caller's list c keeps its original order. The output shows c still reads [3, 1, 2] after the call.
Two more facts about sort matter here. It returns None, not the sorted list, so result = numbers.sort() sets result to None. The sorted list is the list you already had.
Output:
None
[1, 2, 3]Python also has tuples. A tuple is a sequence of values that cannot be changed after it is made. Its written form uses round brackets, such as (3, 1, 2). A tuple has no sort method, so passing a tuple to sort_impure fails. The mistake is shown in the Common mistakes section.
Making a copy costs memory, because the program holds two lists for a while. For small lists this is nothing. For very large lists, a function might sort in place on purpose, and then the name and the comment should say so clearly. The rule for a beginner is simpler: if a function's result is the thing you want, and the input should stay as it was, return a new value and leave the input alone.
Example 3: a counter that makes new ids
A counter is a common source of hidden state. Suppose a program gives each new order a number, starting at 1. The easy way to write this is with a module-level variable that the function increases on every call.
Output:
1
2The function has no parameters, yet it returns a different value on each call. That is a sign of impurity. The answer depends on how many times the function has been called before, which is hidden state. A test of make_id_impure that runs after another test has already called it gets a different answer, so the test depends on the order of the tests.
The word global tells Python that the function will change the module-level next_id. Without it, the line next_id = next_id + 1 would try to create a new local variable, and Python stops with an error. That error is shown in the Common mistakes section.
A pure version gives the caller control of the state. The function computes the next id from a value the caller passes in:
Output:
6
6The function id_after always returns the same value for the same input. It is easy to test, and it is clear what it does. The price is that the caller must now keep track of the current id and pass it in. In a real program, the caller might be a loop or an object that stores the counter. The state is still there, but it now lives in one visible place instead of in a variable that every function can change.
Some programs need a counter that really is shared, such as a counter for the number of requests the whole server has handled. In that case, a module-level variable or an object attribute is the right place for it, and the function that changes it is not pure. That is acceptable, as long as you know the function is impure and you keep the impure part small.
Example 4: a pure summary of a list
The fourth example groups expenses by category and adds up the totals. It uses two features you may not have seen yet. The first is a dataclass, which is a class that Python builds for you from a list of field names. The line @dataclass(frozen=True) is a decorator. A decorator is a line starting with @ that changes the thing defined below it. Here it makes each Expense object unchangeable after it is made.
The second feature is a comprehension. A comprehension is a short way to build a list, a set or a dictionary from a loop in one line. A dictionary, written dict, maps keys to values.
Output:
{'food': 1625, 'travel': 4000}
{'food': 1625, 'travel': 4000}The function takes a list and returns a new dictionary. The function sorted puts the categories in alphabetical order, so the keys appear in that order in the output. The function does not change the list. It does not store the result anywhere. Each call starts from nothing and builds the answer from the list alone, so two calls with the same list give the same dictionary.
Notice that the amounts are stored in cents as whole numbers. Python whole numbers, which have the type int, have no size limit, and they do not have the rounding errors that come from fractional numbers. Fractional numbers, which have the type float, can give results such as this:
Output:
0.30000000000000004Storing money in cents avoids that problem. The frozen=True setting matters too. Because the dataclass is frozen, the function cannot change the items it receives, and an attempt to change one raises FrozenInstanceError.
The function also shows a useful habit. When a function builds a result, it creates a new object for the result. It does not reuse an object the caller gave it, and it does not keep a copy in a module-level variable. That habit makes the function pure almost without effort.
How to spot hidden state
Hidden state is the main reason a function is impure. The job is to find the places where the answer can change without the parameters changing. Read the function body and check each of these.
The first place to look is a name that is not a parameter and not a local variable. If the function reads a variable from the top of the file, and any code can change that variable, the function depends on hidden state. Check whether the variable is ever assigned after it is set up.
The second place is a value from the outside world. Functions that read the current time, a random number, a file, or a line the user typed have answers that change between calls. In Python these show up as calls such as time.time(), random.random(), input() or open(...). Each one can give a different answer for the same parameters.
The third place is an attribute of an object. A method can read and change the attributes of its object, such as self.total. If it reads an attribute that is not passed in, the answer depends on the object's state.
The fourth place is an argument that the function changes. Look for calls such as append, sort, remove, pop or update on a parameter, and for assignments such as items[0] = 5 to a parameter. Each of these is a side effect that the caller can see only by reading the function.
The fifth place is output. A call to print, or to a function that writes a file, is a side effect. It does not change the answer the function returns, but it changes the world, and a test cannot check it as easily.
A simple test helps too. Call the function twice in a row with the same arguments and see whether the results match. If they do not, the function reads hidden state. This does not prove that the function is pure, because a function can be impure in a way that does not show in one test. But if the results differ, the function is definitely impure.
Common mistakes
Reading a module-level variable and forgetting it
The function looks pure because its parameter list is clean, but the body reads tax_rate or some other variable from the top of the file. The fix is to read the body, not just the signature, and to pass the variable in as a parameter.
Changing a list the caller passed in
A function that calls sort, append or remove on its parameter changes the caller's list. Here is what happens when the caller passes a tuple to a function that sorts its argument in place:
def sort_impure(xs):
xs.sort()
return xs
print(sort_impure((3, 1, 2)))The real output on Python 3.14 is shown below. The directory part of the file path is left out, because it depends on where the file is saved.
Traceback (most recent call last):
File "sort_demo.py", line 5, in <module>
print(sort_impure((3, 1, 2)))
~~~~~~~~~~~^^^^^^^^^^^
File "sort_demo.py", line 2, in sort_impure
xs.sort()
^^^^^^^
AttributeError: 'tuple' object has no attribute 'sort'The last line says what went wrong: a tuple has no sort method. The line above it, xs.sort(), shows where. The fix is to use sorted(xs), which works on a tuple and returns a new list, as sorted_copy does.
Using a default argument that holds a list
A default argument is a value used when the caller leaves that argument out. Python builds the default once, when it reads the def line, and every call that leaves the argument out shares the same object. With a list, the list keeps growing across calls.
Output:
['a']
['a', 'b']
['c']The second call returns ['a', 'b'], although the caller passed only "b". The first call changed the default list, and the second call found it changed. The fix is to use None as the default and make a new list inside the function:
Output:
['a']
['b']Saving answers in a cache
Memoisation means saving the answer to a call, so that the next call with the same input can return the saved answer without doing the work again. The standard library has a decorator for this, functools.lru_cache. The answers are the same, so the function still looks pure from outside. But the saved answers are hidden state. A cache of this kind is acceptable when it does not change the answer. The example below uses print inside the function to show when the work runs.
Output:
computing 4
16
16
computing 5
25
CacheInfo(hits=1, misses=2, maxsize=None, currsize=2)The second call with 4 returns 16 without printing computing 4, because the answer was saved. The cache_info line counts the calls: one hit, which was the second call, and two misses, which were the first calls with 4 and with 5. The function's output is the same either way, but the saved answers are shared state, so keep the cache private and make sure it cannot change what the function returns.
Assuming that no side effects means pure
A function with no side effects can still be impure if it reads hidden state. A function that returns x * 2 where x is a module-level variable changes nothing, yet its answer changes when x changes. Purity needs both rules: no hidden reads and no side effects.
Writing a test that depends on the order of calls
If the first test calls make_id_impure and the second test expects 1, the second test fails when run after the first one. The test passes in isolation and fails in the full suite. This is the sign of hidden state, and the fix is to pass the state in, as id_after does.
Forgetting global when assigning
Reading a module-level variable needs no extra word. Assigning to it inside a function needs global. Without the word, the assignment creates a local variable, and the read on the same line fails:
Output:
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a valueThe error means the name counter is treated as local everywhere in the function, because the assignment makes it local, so the read on the right side has no value yet. Adding global counter as the first line of the function fixes it, but a pure function avoids the situation by taking the value as a parameter.
Practice
Each exercise has its answer below it. Try to write your answer before you read it.
Exercise 1
The function below is impure. Find the hidden state and explain in one sentence why the result can change.
limit = 10
def within_limit(n):
return n <= limitShow the answer
The function reads the module-level variable limit, which is not a parameter and can be changed by any code. The same call within_limit(7) returns True or False depending on the current value of limit.
Exercise 2
Rewrite within_limit so that it is pure. Test it with 7 and a limit of 5.
Show the answer
Make the limit a parameter: def within_limit(n, limit): return n <= limit. The call within_limit(7, 5) returns False, and within_limit(3, 5) returns True. The answer depends only on the two inputs.
Exercise 3
Write a pure function doubled(xs) that returns a new list with each value doubled. The input list must not change. Use values = [1, 2, 3] to test it, then print the input afterwards.
Show the answer
Build a new list with a comprehension: return [x * 2 for x in xs]. Printing doubled(values) gives [2, 4, 6], and printing values afterwards gives [1, 2, 3], because the function built a new list and did not change the input.
Exercise 4
A function greet(name) prints Hello, Asha and returns nothing. Is it pure? Explain your answer, and say how you could separate the printing from the work.
Show the answer
It is not pure, because printing changes the outside world, even though it does not read hidden state. To separate the parts, write a pure function greeting(name) that returns f"Hello, {name}", and let the main part of the file call print(greeting("Asha")).
Exercise 5
A function reads the current time and returns True if the current second is even. Is this pure? What is the hidden input?
import time
def is_even_second():
return int(time.time()) % 2 == 0Show the answer
It is not pure. The hidden input is the current time, which is not a parameter. Two calls made at different moments can return different answers with the same empty parameter list. To make it pure, pass the time in as a parameter, such as def is_even(seconds): return seconds % 2 == 0.
Summary
- A pure function returns the same output for the same inputs and changes nothing outside itself.
- Printing, changing a module-level variable, changing an argument and reading the time or a random number all make a function impure.
- Move hidden state into parameters to make a function pure, and keep the impure parts, such as printing and counters, small and visible.
- To avoid changing a caller's list, use
sortedto build a new list and return it, and do not use a default argument that holds a list. - Find hidden state by reading the function body, checking names that are not parameters, outside reads, object attributes, changed arguments and output.