Pure Functions and Side Effects
- 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
20Exactly 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:
- 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.
- 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 Effect | Example |
|---|---|
| Changing a global variable | global total and then total += price |
| Changing a list or a dictionary that was received | prices[i] = ... on the list that came in as a parameter |
| Input and output with the user | print(...), input(...) |
| Writing to a file or a database | open(..., "w"), INSERT |
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 totalFirst 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
10Breaking down the code line by line
| Line | Explanation |
|---|---|
def add_price(total, price): | Everything the function needs comes through the parameters. |
return total + price | It 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)) twice | The 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
| Function | Pure? | Why |
|---|---|---|
square | Yes | The result depends only on n, and nothing changes |
log_square | No | print is a Side Effect — output to the screen |
add_item | No | append changes the list that was received (Mutation) |
with_item | Yes | cart + [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 totalThe 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
10Show 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
10Everything 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] * TAXThe 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)— callscalculate_totaland prints the result.
Test code:
show_total([10, 20, 30])Expected output:
Total: 60Show solution
def calculate_total(prices):
return sum(prices)
def show_total(prices):
print("Total:", calculate_total(prices))
show_total([10, 20, 30])Total: 60The 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.