Pure Functions and Side Effects

Lesson goals
  • Explain what a Pure Function is — the same input, always the same result, without changing anything outside
  • Recognize Side Effects in code — global, changing a received list, printing, writing to a file
  • Turn a function that changes its input into a pure version that returns a new value
  • Explain why pure functions are easier to test and to reuse

What will we learn?

  • What a Pure Function is.
  • What a Side Effect is — and which kinds exist.
  • The trap of changing a list that was received as a parameter.
  • How to write a pure version of a function.

Why do we need this?

Here is a function that looks innocent:

total = 0

def add_to_total(price):
    global total
    total += price
    return total

print(add_to_total(10))
print(add_to_total(10))

Output:

10
20

Exactly the same call — and a different result. The function depends on an outside variable and also changes it. To know what it will return, you need to know the whole history of the program. It is hard to test, and hard to trust.

Explanation from the basics

What a Pure Function is

A pure function meets two conditions:

  1. The same input → always the same result. The result depends only on the parameters — not on global variables, not on the time, not on input from the user.
  2. No Side Effects. It does not change anything outside it. Everything it “puts out” comes out through return.

What counts as a Side Effect

Side EffectExample
Changing a global variableglobal total and then total += price
Changing a list or a dictionary that was receivedprices[i] = ... on the list that came in as a parameter
Input and output with the userprint(...), input(...)
Writing to a file or a databaseopen(..., "w"), INSERT
Analogy: a juice machine

A pure function is like a juicer: you put in 3 oranges — a glass of juice comes out. Always the same amount, and the juicer touches nothing else in the kitchen. An impure function is a juicer that sometimes also moves the fridge — and suddenly something else in the house does not work, and you have no idea why.

Syntax

There is no special syntax — it is a way of writing functions:

# pure: result depends only on the parameters
def add_price(total, price):
    return total + price

# not pure: reads and changes something outside
def add_to_total(price):
    global total
    total += price
    return total

First code example

The same function from the start of the lesson, in a pure version. week-2/pure_total.py:

def add_price(total, price):
    return total + price

total = 0
total = add_price(total, 10)
total = add_price(total, 10)
print(total)

print(add_price(0, 10))
print(add_price(0, 10))

Output:

20
10
10

Breaking down the code line by line

LineExplanation
def add_price(total, price):Everything the function needs comes through the parameters.
return total + priceIt changes nothing — it only calculates and returns.
total = add_price(total, 10)The caller decides what to do with the result: here — store it in total.
print(add_price(0, 10)) twiceThe same input → the same result, 10, every time.

Another example

The trap: changing a list that was received. week-2/discount_trap.py:

def add_discount(prices):
    for i in range(len(prices)):
        prices[i] = prices[i] * 0.9
    return prices

original = [100, 200]
discounted = add_discount(original)
print(discounted)
print(original)

Output:

[90.0, 180.0]
[90.0, 180.0]

original changed! As we learned in the lesson on Mutability, a list is passed to a function as a reference — prices and original are two names for the same list. Whoever used original later in the program suddenly gets different prices.

The pure version — build a new list:

def add_discount(prices):
    result = []
    for price in prices:
        result.append(price * 0.9)
    return result

original = [100, 200]
discounted = add_discount(original)
print(discounted)
print(original)

Output:

[90.0, 180.0]
[100, 200]

Now original stays exactly as it was, and discounted is a completely new list.

Common mistake

The mistake: thinking that every function must be pure. A program that does not print, does not save to a file and does not write to a database — does nothing useful. Side Effects are a necessary part of every program.

The rule is to separate:

  • The calculation — in pure functions (calculate_total, apply_discount).
  • The communication with the world — in a few clear places (print, saving to a file).
def calculate_total(prices):        # pure: easy to test
    return sum(prices)

def show_total(prices):             # side effect: printing
    print("Total:", calculate_total(prices))

Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

Exercise 1: Pure or not?

For each function — is it pure? If not, what is the Side Effect?

def square(n):
    return n * n

def log_square(n):
    print("squaring", n)
    return n * n

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

def with_item(cart, item):
    return cart + [item]
Show solution
FunctionPure?Why
squareYesThe result depends only on n, and nothing changes
log_squareNoprint is a Side Effect — output to the screen
add_itemNoappend changes the list that was received (Mutation)
with_itemYescart + [item] creates a new list, and the original was not touched

Exercise 2: The same call — a different result

total = 0

def add_to_total(price):
    global total
    total += price
    return total

The problem: every call to add_to_total(10) returns a different result (10, then 20, then 30), because the function depends on a global variable and changes it.

Rewrite the function as a pure version named add_price(total, price) — it receives the current total as a parameter and returns the new total, without global. Run the test code, which shows that the same input always gives the same result:

total = 0
total = add_price(total, 10)
total = add_price(total, 10)
print(total)

print(add_price(0, 10))
print(add_price(0, 10))

Expected output:

20
10
10
Show solution
def add_price(total, price):
    return total + price


total = 0
total = add_price(total, 10)
total = add_price(total, 10)
print(total)

print(add_price(0, 10))
print(add_price(0, 10))
20
10
10

Everything the function needs comes through the parameters, and the result comes out only through return. The caller decides what to do with it.

Exercise 3: Don’t change the list you received

TAX = 1.17

def apply_tax(prices):
    for i in range(len(prices)):
        prices[i] = prices[i] * TAX

The problem: the function changes the list it received, so after the call the original prices are lost.

Write a pure version of apply_tax(prices), which returns a new list with the prices including tax (each price rounded to two digits) and does not touch the list it received. Run the test code — the first line proves that the original list did not change:

prices = [100, 50]
with_tax = apply_tax(prices)
print(prices)
print(with_tax)

Expected output:

[100, 50]
[117.0, 58.5]
Show solution
TAX = 1.17


def apply_tax(prices):
    result = []
    for price in prices:
        result.append(round(price * TAX, 2))
    return result


prices = [100, 50]
with_tax = apply_tax(prices)
print(prices)
print(with_tax)
[100, 50]
[117.0, 58.5]

TAX is a constant that does not change, so reading it does not hurt the purity — the same input still always gives the same result.

Exercise 4: Separating calculation from printing

def show_total(prices):
    total = 0
    for price in prices:
        total = total + price
    print("Total:", total)

The function both calculates and prints. Split it into two:

  • calculate_total(prices) — pure: only calculates and returns the sum;
  • show_total(prices) — calls calculate_total and prints the result.

Test code:

show_total([10, 20, 30])

Expected output:

Total: 60
Show solution
def calculate_total(prices):
    return sum(prices)


def show_total(prices):
    print("Total:", calculate_total(prices))


show_total([10, 20, 30])
Total: 60

The calculation (calculate_total) is pure and easy to test. The Side Effect (print) is concentrated in one clear place.

Exercise 5: The top three — without changing anything

Write a pure function top_three(numbers) that returns the three largest numbers, from the largest to the smallest, without changing the original list. Also print the original list to prove it did not change.

scores = [70, 95, 88, 60, 99]
print(top_three(scores))
print(scores)

Expected output:

[99, 95, 88]
[70, 95, 88, 60, 99]
Show solution
def top_three(numbers):
    return sorted(numbers, reverse=True)[:3]


scores = [70, 95, 88, 60, 99]
print(top_three(scores))
print(scores)
[99, 95, 88]
[70, 95, 88, 60, 99]

sorted returns a new sorted list, and Slicing ([:3]) returns another new list — the original list was not touched, so the function is pure.

Summary

  • Pure Function = the same input → always the same result, and without changing anything outside.
  • Side Effects: changing a global, changing a received list or dictionary, print/input, writing to a file or a database.
  • A list is passed as a reference — a function that changes it changes the original. The pure version builds a new list.
  • Pure functions are easy to test, predictable, and easy to reuse.
  • Not everything can be pure — separate the pure calculation from the communication with the world.

Official documentation