Skip to content

Pure Functions & Side Effects

A pure function is a function that, given the same inputs, always returns the same output and produces no observable side effects. This simple property has profound consequences: pure functions are easier to test, easier to reason about, safe to cache, safe to parallelize, and safe to refactor.


What Makes a Function Pure?

A function is pure if it satisfies two conditions:

  1. Deterministic — The same inputs always produce the same output.
  2. No side effects — The function does not modify any external state or interact with the outside world.
PURE FUNCTION:
Input A ──► ┌──────────┐ ──► Output X
│ f(A) │
Input B ──► │ │
│ No side │
│ effects │
└──────────┘
- No reading from global state
- No writing to global state
- No I/O (disk, network, console)
- No randomness
- No current time
- Always: f(A, B) = X
# PURE: same input always produces same output
def add(a: int, b: int) -> int:
return a + b
def area_of_circle(radius: float) -> float:
import math
return math.pi * radius ** 2
def sort_list(items: list) -> list:
return sorted(items) # Returns new list, does not modify input
# IMPURE: depends on external state
total = 0
def add_to_total(n: int) -> int:
global total
total += n # Side effect: modifies external state
return total # Result depends on previous calls
print(add_to_total(5)) # 5
print(add_to_total(5)) # 10 -- same input, different output!
# IMPURE: performs I/O
def greet(name: str) -> None:
print(f"Hello, {name}!") # Side effect: console output
# IMPURE: depends on current time
import time
def get_greeting() -> str:
hour = time.localtime().tm_hour # External dependency
if hour < 12:
return "Good morning"
return "Good afternoon"

Referential Transparency

An expression is referentially transparent if it can be replaced by its value without changing the program’s behavior. Pure functions are always referentially transparent.

Referentially transparent:
add(2, 3) can always be replaced by 5
result = add(2, 3) * add(2, 3)
result = 5 * 5 -- safe substitution
result = 25
NOT referentially transparent:
random() cannot be replaced by any single value
result = random() * random()
result = 0.7 * 0.3 -- WRONG! Each call may differ

Catalog of Side Effects

A side effect is anything a function does besides computing a return value.

Side EffectExampleWhy It Is a Problem
Mutating inputlist.sort()Caller’s data changes unexpectedly
Mutating global statecounter += 1Hidden dependencies between functions
Console I/Oprint(...)Output cannot be captured by the caller
File I/Oopen("file").write(...)Depends on filesystem state
Network I/Orequests.get(url)Depends on external service availability
Database accessdb.query(...)Depends on database state
Randomnessrandom.random()Non-deterministic output
Current timedatetime.now()Different result on every call
Throwing exceptionsraise ValueError(...)Alters control flow outside the function

Making Impure Code Purer

You cannot eliminate side effects entirely — a program without side effects does nothing observable. The strategy is to push side effects to the edges of your system and keep the core logic pure.

Impure Shell / Pure Core Architecture:
┌────────────────────────────────────────────────┐
│ Impure Shell │
│ (I/O, database, network, user input) │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Pure Core │ │
│ │ (business logic, transformations, │ │
│ │ validation, calculations) │ │
│ │ │ │
│ │ No I/O, no mutation, no randomness │ │
│ └────────────────────────────────────────┘ │
│ │
│ Read input → Pass to pure core → Write output │
└────────────────────────────────────────────────┘
# BEFORE: Impure function that mixes logic and I/O
def process_order_impure(order_id: str):
order = db.get_order(order_id) # I/O
if order['total'] > 100:
order['discount'] = order['total'] * 0.1
else:
order['discount'] = 0
order['final_total'] = order['total'] - order['discount']
db.save_order(order) # I/O
send_email(order['email'], order) # I/O
# AFTER: Pure core + impure shell
# Pure core: no I/O, no mutation
def calculate_discount(total: float) -> float:
"""Pure: same input always gives same output."""
return total * 0.1 if total > 100 else 0.0
def compute_order_totals(order: dict) -> dict:
"""Pure: returns a new dict without modifying input."""
discount = calculate_discount(order['total'])
return {
**order,
'discount': discount,
'final_total': order['total'] - discount,
}
# Impure shell: handles I/O at the edges
def process_order(order_id: str):
order = db.get_order(order_id) # I/O (shell)
updated = compute_order_totals(order) # Pure (core)
db.save_order(updated) # I/O (shell)
send_email(updated['email'], updated) # I/O (shell)
# The pure functions are trivially testable:
def test_calculate_discount():
assert calculate_discount(150) == 15.0
assert calculate_discount(50) == 0.0
assert calculate_discount(100) == 0.0
assert calculate_discount(100.01) == 10.001

Immutability Patterns

Defensive Copying

Always return new data structures instead of modifying inputs.

# BAD: Mutates the input list
def add_item_bad(items: list, item) -> list:
items.append(item) # Mutates the original!
return items
original = [1, 2, 3]
result = add_item_bad(original, 4)
print(original) # [1, 2, 3, 4] -- original was modified!
# GOOD: Returns a new list
def add_item(items: list, item) -> list:
return [*items, item] # New list via unpacking
original = [1, 2, 3]
result = add_item(original, 4)
print(original) # [1, 2, 3] -- unchanged
print(result) # [1, 2, 3, 4]
# Immutable dict update
def set_field(record: dict, key: str, value) -> dict:
return {**record, key: value}
user = {"name": "Alice", "age": 30}
updated = set_field(user, "age", 31)
print(user) # {"name": "Alice", "age": 30}
print(updated) # {"name": "Alice", "age": 31}

Dependency Injection for Purity

When a function needs something impure (like the current time or a random number), inject it as a parameter instead of calling it directly. This makes the function pure and testable.

from datetime import datetime
# IMPURE: calls datetime.now() internally
def get_greeting_impure() -> str:
hour = datetime.now().hour
if hour < 12: return "Good morning"
if hour < 18: return "Good afternoon"
return "Good evening"
# PURE: time is injected
def get_greeting(current_hour: int) -> str:
if current_hour < 12: return "Good morning"
if current_hour < 18: return "Good afternoon"
return "Good evening"
# In production:
greeting = get_greeting(datetime.now().hour)
# In tests (no mocking needed!):
assert get_greeting(9) == "Good morning"
assert get_greeting(14) == "Good afternoon"
assert get_greeting(20) == "Good evening"
# Injecting randomness
import random
# IMPURE
def roll_dice_impure() -> int:
return random.randint(1, 6)
# PURE: inject the random function
def roll_dice(rng=random.randint) -> int:
return rng(1, 6)
# Test with deterministic "randomness"
assert roll_dice(rng=lambda a, b: 4) == 4

Memoization: Caching Pure Functions

Because pure functions always return the same output for the same input, their results can be cached (memoized) safely.

from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Without memoization: O(2^n) time
# With memoization: O(n) time, O(n) space
print(fibonacci(100)) # 354224848179261915075 (instant)
# lru_cache only works with hashable arguments
# For unhashable args, use a manual cache:
def memoize(fn):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
@memoize
def expensive_calculation(x, y):
return x ** y + y ** x

Pure Functions and Testing

Pure functions are the easiest code to test. No mocks, no stubs, no setup, no teardown — just input and expected output.

Testing pure vs impure functions:
Pure function test:
assert add(2, 3) == 5 -- that is it. Done.
Impure function test:
1. Set up mock database
2. Seed test data
3. Set up mock email service
4. Set up mock clock
5. Call the function
6. Assert database was called with correct args
7. Assert email was sent
8. Tear down mocks
9. Clean up test data
Pure functions eliminate most of this ceremony.

Summary

ConceptKey Takeaway
Pure functionSame inputs produce same output; no side effects
Referential transparencyExpression can be replaced by its value without changing behavior
Side effectsAnything beyond computing a return value (I/O, mutation, randomness)
Impure shell / pure corePush side effects to the edges; keep business logic pure
ImmutabilityReturn new data structures instead of modifying inputs
Dependency injectionInject impure dependencies as parameters for testability
MemoizationSafe to cache pure function results; same input always gives same output