software-design/pure-functions-why-the-same-call-can-give-two-different-answers.md

Pure functions: why the same call can give two different answers

Building software · 5 min read ·

Call a function twice with the same input and get two different answers? Python isn't broken. The function is doing something extra, called a side effect.

A pure function only uses its arguments and changes nothing else, so it always answers the same way. Try three small Python functions, compare each with a pure rewrite, and see why pure ones are easier to predict and test.

Two code boxes: a function that changes the cart gives two different answers, a pure rewrite gives the same answer twice

One call, two different answers

You write a small function, call it with the same input twice, and get two different answers. Python is not broken. The function is doing something extra that you may not have noticed.

Let us define the words first. A function is a named piece of code you can run again and again. The values you hand it are its arguments, and the value it hands back is its return value. A side effect is anything else a function does: changing a list that lives outside it, changing a variable, printing to the screen, saving a file, sending a message.

A function with no side effects, whose answer depends only on its arguments, is called a pure function. Same arguments in, same answer out, every time, and nothing else in the program is touched. Here is one:

def add_tax(price):
    return round(price * 1.2, 2)

print(add_tax(10))
print(add_tax(10))

Output when we ran it with Python 3.13:

12.0
12.0

You could call add_tax(10) a million times and never be surprised. Now meet a function that is not pure.

A function that changes your list

def add_item(cart, item):
    cart.append(item)
    return cart

my_cart = ["tea"]
print(add_item(my_cart, "milk"))
print(add_item(my_cart, "milk"))
print(my_cart)

Real output:

['tea', 'milk']
['tea', 'milk', 'milk']
['tea', 'milk', 'milk']

The same call gave two different answers, and afterwards my_cart itself has changed. The reason is how Python passes lists: the function does not get a private copy, it gets the same list your program is holding. So cart.append(item) changes your list. That change is the side effect.

Nothing is wrong with the code on the screen. The trouble is that the answer now depends on history: what was in the list before you called. To predict a call, you must know every earlier call.

Try it yourself

Pick a function, switch between the original and a pure rewrite, and press the button to call it twice with the same input. Watch the two panels: what came back, and what happened to things outside the function.

The fix: return something new

You can write the cart function so that it never touches the original list. The + operator on two lists builds a brand-new list, and the old one stays as it was:

def with_item(cart, item):
    return cart + [item]

my_cart = ["tea"]
print(with_item(my_cart, "milk"))
print(with_item(my_cart, "milk"))
print(my_cart)
['tea', 'milk']
['tea', 'milk']
['tea']

Now both calls agree, and my_cart is untouched. If you want to keep the new cart, you say so on purpose: my_cart = with_item(my_cart, "milk"). The change is visible right there on the line where it happens.

The counter in the interactive follows the same idea. The first version reaches out to a variable called counter that lives outside the function (the keyword global allows that), so every call gives a new number. The pure version, next_id_pure(last), is told the last number and works out the next one. Hand it 0 twice and you get 1 twice.

Why testers love pure functions

A test is a few lines of code that run your function and check the answer. For a pure function a test is tiny:

assert with_item([], "tea") == ["tea"]
assert with_item(["tea"], "milk") == ["tea", "milk"]
print("both checks passed")

(assert stops the program with an error if the claim after it is false.) Running this printed both checks passed. There was nothing to set up beforehand and nothing to clean up afterwards. The order of the checks does not matter, and a failure points straight at the function.

Testing the impure add_item is fiddlier. You must create a fresh list for every check, because an earlier check may have left it changed. And if you forget, a test can pass or fail depending on which test ran first, which is a very confusing afternoon.

You cannot remove every side effect

Programs exist to have effects: showing a page, saving an order, sending an email. A program with no side effects would be a very quiet calculator. So the goal is not to ban them. The goal is to keep them in a few obvious places.

A common habit is to put the thinking in small pure functions (work out the total, decide the discount, build the new cart) and let one thin layer do the touching: read the file, call the pure functions, save the result, print the message. The thinking is then easy to test, and the risky part is short enough to read in one go.

Printing is a side effect too, and perfectly fine in the right place. A useful rule of thumb when you write a function: ask whether it calculates or does. Try to keep each function to one of the two.

What to take away

A pure function depends only on its arguments and changes nothing else, so the same call always gives the same answer. When a function surprises you, look for the quiet side effect: an append on a list you were handed, a variable declared global, a file, the clock. Returning a new value instead of changing the old one is often a small edit that makes the code easier to follow and test.

$ ls software-design/

see all →
software-design/the-observer-pattern-how-a-shop-tells-many-functions-about-one-order-w.md The observer pattern: how a shop tells many functions about one order without knowing them A shop that must email a receipt, update stock and add loyalty points ends up with one function that knows about everything. Every new… 5 min read · 27 Oct software-design/refactoring-how-to-tidy-working-code-without-breaking-it.md Refactoring: how to tidy working code without breaking it Refactoring means changing how code is built on the inside without changing what it does. It sounds risky, but there is a simple routine… 6 min read · 24 Oct software-design/unit-tests-why-four-passing-tests-don-t-prove-a-function-works.md Unit tests: why four passing tests don't prove a function works Your function gave the right answer four times. Is it correct? Not necessarily: passing tests only show the bug isn't where you looked… 4 min read · 21 Oct software-design/idempotency-why-clicking-pay-twice-should-charge-you-once.md Idempotency: why clicking Pay twice should charge you once You tap Pay, the screen shows an error, you tap again, and your card is charged twice. Networks drop messages, so a phone often cannot tell… 6 min read · 18 Oct software-design/state-machines-why-an-order-can-t-be-delivered-before-it-s-paid.md State machines: why an order can't be delivered before it's paid An online order can be in the cart, paid, shipped, delivered or cancelled, but what stops the code from delivering an order nobody paid… 4 min read · 15 Oct software-design/dependency-injection-why-a-test-passes-at-9am-and-fails-at-3pm.md Dependency injection: why a test passes at 9am and fails at 3pm A test that passes in the morning and fails in the afternoon, with no change to the code, is a puzzle many beginners meet. The cause is… 5 min read · 12 Oct