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.

What came back

Outside the function

Three small Python functions. The page simulates what Python does for these exact examples; it is not running Python itself.

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.