Skip to main content

Python Pure Functions Practice Problems & Exercises

Practice: Pure Functions

11 problems4 Easy4 Medium3 Hard45-60 min
← Back to lesson

Easy

#1Identify Pure vs ImpureEasy
pureimpureside-effectsidentification

Classify each function as pure or impure. Justify each classification by identifying the source of impurity (if any).

Python
import time

def add(a, b):
    return a + b

def get_timestamp():
    return time.time()

log_buffer = []

def append_to_list(item):
    log_buffer.append(item)
    return log_buffer

def format_name(first, last):
    return f"{first.strip().title()} {last.strip().title()}"

# Verify purity by testing same-input same-output for pure ones
add_check = add(3, 4) == add(3, 4)
ts1 = get_timestamp()
ts2 = get_timestamp()
ts_same = ts1 == ts2

print(f"add is pure: {add_check}")
print(f"get_timestamp is pure: {ts_same}")

before = len(log_buffer)
append_to_list("x")
after = len(log_buffer)
print(f"append_to_list is pure: {before == after}")
print(f"format_name is pure: {format_name('alice', 'smith') == format_name('alice', 'smith')}")
Solution
add is pure: True
get_timestamp is pure: False
append_to_list is pure: False
format_name is pure: True

Classification breakdown:

FunctionPure?Reason
addYesOnly arithmetic on parameters, no I/O, no mutation
get_timestampNoReturns time.time() — different value each call (non-deterministic)
append_to_listNoMutates log_buffer (external state), different return value as list grows
format_nameYesString operations on parameters only, same input always yields same output

The two criteria for purity:

  1. Determinism: Same inputs always produce the same output.
  2. No side effects: Does not read or write external state (globals, files, network, random, time).

append_to_list violates both: it mutates external state and returns a growing list (different output for the same input after the first call).

Expected Output
add is pure: True
get_timestamp is pure: False
append_to_list is pure: False
format_name is pure: True
Hints

Hint 1: A pure function always returns the same output for the same input and has no observable side effects — no I/O, no mutation of external state, no random values.

Hint 2: Check each function: does it read or write anything outside its parameters? Does it mutate any mutable argument?

#2Convert Impure to PureEasy
refactoringpuremutationreturn-value

Rewrite the impure functions below as pure equivalents. Verify the originals still work correctly after the pure versions are called.

Python
# --- Impure versions ---
running_total = 0

def impure_add_to_total(amount):
    global running_total
    running_total += amount
    return running_total

def impure_double_items(lst):
    for i in range(len(lst)):
        lst[i] *= 2
    return lst

# --- Pure versions ---
def pure_add_to_total(current_total, amount):
    return current_total + amount

def pure_double_items(lst):
    return [x * 2 for x in lst]

# Test impure
impure_add_to_total(10)
impure_add_to_total(10)
impure_add_to_total(10)
print(f"impure_total after 3 calls: {running_total}")

# Test pure
t = 0
results = []
for _ in range(3):
    t = pure_add_to_total(t, 10)
    results.append(t)
print(f"pure_total calls: {results[0]} {results[1]} {results[2]}")

# Test list purity
original = [1, 2, 3, 4]
pure_result = pure_double_items(original)
print(f"original list unchanged: {original == [1, 2, 3, 4]}")
print(f"pure result: {pure_result}")
Solution
impure_total after 3 calls: 30
pure_total calls: 10 20 30
original list unchanged: True
pure result: [2, 4, 6, 8]

Key transformations:

Impure → Pure (global state):

  • Before: running_total is a module-level global. The function has a side effect (mutating it).
  • After: Pass the current total as a parameter. The function is now a pure arithmetic operation. The caller decides how to accumulate state.

Impure → Pure (in-place mutation):

  • Before: lst[i] *= 2 modifies the caller's list in-place. The caller's data changes as a side effect.
  • After: [x * 2 for x in lst] builds a new list without touching the original. The caller's list is unaffected.

Rule of thumb: If a function takes a mutable argument, it is suspicious. If it modifies that argument in-place, it is impure. The pure version always returns a new value instead of modifying an existing one.

Expected Output
impure_total after 3 calls: 30
pure_total calls: 10 20 30
original list unchanged: True
pure result: [2, 4, 6, 8]
Hints

Hint 1: Move all mutable state out of the function. Instead of modifying a global or the input, compute a new value and return it.

Hint 2: For list mutation: use a list comprehension to build and return a new list rather than modifying the input list in-place.

#3Referential Transparency CheckEasy
referential-transparencysubstitutionpureexpression

Verify referential transparency for a pure function and show it breaks for an impure function that mutates its argument.

Python
# Pure function — referentially transparent
def area(side):
    return side * side

result_direct = area(10)
result_substituted = area(10)  # any call with same args can be replaced by return value

print(f"direct: {result_direct}")
print(f"substituted: {result_substituted}")
print(f"referentially transparent: {result_direct == result_substituted}")

# Impure function — NOT referentially transparent
shared_list = [1, 2, 3]

def append_and_return(lst, item):
    lst.append(item)
    return lst

call1 = list(append_and_return(shared_list, 4))  # snapshot after first call
# Reset
shared_list.pop()
call2 = list(append_and_return(shared_list, 4))  # snapshot after second call

# Same call, same arguments — different observable effect (shared_list differs)
print(f"impure direct: {[1, 2, 3]}")
print(f"impure substituted: {call1}")
print(f"referentially transparent: {[1, 2, 3] == [1, 2, 3, 4]}")
Solution
direct: 100
substituted: 100
referentially transparent: True
impure direct: [1, 2, 3]
impure substituted: [1, 2, 3, 4]
referentially transparent: False

Referential transparency defined:

An expression is referentially transparent if it can be replaced by its value in any context without changing the program's meaning. For functions, this means f(x) and the value it returns are interchangeable everywhere f(x) appears.

area(10) always returns 100. Replacing area(10) with 100 anywhere in the program produces identical behaviour — that is referential transparency.

append_and_return(shared_list, 4) modifies shared_list as a side effect. Two calls with "the same arguments" are not the same, because the state of shared_list changes between calls. Replacing the first call with its return value would not capture this mutation — the behaviour of the program would change.

Why this matters: Referentially transparent code is safe to cache (memoize), reorder, and parallelise. The compiler or runtime can substitute cached values without re-executing the function.

Expected Output
direct: 100
substituted: 100
referentially transparent: True
impure direct: [1, 2, 3]
impure substituted: [1, 2, 3, 4]
referentially transparent: False
Hints

Hint 1: A function is referentially transparent if you can replace any call to it with its return value without changing program behaviour.

Hint 2: Call the impure function twice with the same argument. If the results differ, it is NOT referentially transparent.

#4Pure Function Testing AdvantageEasy
testingpuredeterminismno-mocks

Write three pure business-logic functions and a test harness demonstrating that pure functions are trivially testable — no mocks, no setup, no teardown needed.

Python
# Pure business logic
def apply_discount(price, discount_pct):
    return round(price * (1 - discount_pct / 100), 2)

def apply_tax(price, tax_rate):
    return round(price * (1 + tax_rate / 100), 2)

def calculate_total(items, discount_pct, tax_rate):
    subtotal = sum(item["price"] * item["qty"] for item in items)
    discounted = apply_discount(subtotal, discount_pct)
    return apply_tax(discounted, tax_rate)

# Tests — no mocks, no fixtures, just inputs and expected outputs
tests = [
    ("test_discount", apply_discount(100.0, 20) == 80.0),
    ("test_tax",      apply_tax(80.0, 10) == 88.0),
    ("test_total",    calculate_total(
        [{"price": 50.0, "qty": 2}, {"price": 20.0, "qty": 1}],
        discount_pct=10,
        tax_rate=5,
    ) == 113.4),
]

all_passed = True
for name, result in tests:
    status = "PASS" if result else "FAIL"
    if not result:
        all_passed = False
    print(f"{name}: {status}")

print(f"all tests passed: {all_passed}")
Solution
test_discount: PASS
test_tax: PASS
test_total: PASS
all tests passed: True

Why pure functions are so testable:

  1. No setup required. There is no shared state to initialise before the test.
  2. No mocks required. Pure functions do not call databases, external APIs, file systems, or clocks — nothing to mock.
  3. No teardown required. The function leaves no side effects that would pollute subsequent tests.
  4. Deterministic. apply_discount(100, 20) is always 80.0. Run the test once or a million times — the result never changes.

This is why functional programming advocates argue that a large pure core with a thin impure shell at the boundaries is the easiest architecture to test. All the complex logic lives in pure functions; only the I/O layer (database, network, file) needs mocking.

calculate_total is also pure despite calling two other functions — purity composes. A function that only calls other pure functions and does no I/O or mutation is itself pure.

Expected Output
test_discount: PASS
test_tax: PASS
test_total: PASS
all tests passed: True
Hints

Hint 1: Pure functions need no mocks, no test fixtures, no setup/teardown. Just call with inputs and assert the output.

Hint 2: Write three small pure business-logic functions, then test each with a simple equality check.


Medium

#5Hidden Side Effect DetectionMedium
side-effectsmutationdefault-argumentglobal-read

Identify and explain four subtle sources of impurity. For each, predict the output and explain what makes it impure.

Python
# f1: reads a mutable global
counter = [0]
def f1(x):
    counter[0] += 1
    return x

print(f"f1 calls: {[f1(5) for _ in range(3)]}")
print(f"default arg mutated: {counter[0] == 3}")

# f3: depends on external mutable state via closure
multiplier = [10]
def make_f3():
    def f3(x):
        return x * multiplier[0]
    return f3

f3 = make_f3()
results = []
for m in [1, 2, 3]:
    multiplier[0] = m * 10
    results.append(f3(1))
print(f"f3 calls: {results}")

# f4: reads a module-level variable that changes
config = {"mode": "A"}
def f4():
    return config["mode"]

modes = []
for mode in ["A", "A", "A"]:
    config["mode"] = mode
    modes.append(f4())
print(f"f4 calls: {modes}")
Solution
f1 calls: [5, 5, 5]
default arg mutated: True
f3 calls: [10, 20, 30]
f4 calls: ['A', 'A', 'A']

Sources of impurity:

f1 — mutates external mutable state: counter[0] += 1 modifies a module-level list on every call. Even though the return value is deterministic (x), the function has a side effect. counter[0] is 3 after three calls, proving the mutation.

f3 — closure over mutable external state: f3 captures multiplier from its enclosing scope, but multiplier is a module-level list that changes between calls. The same call f3(1) produces 10, 20, and 30 on successive iterations — non-deterministic output, therefore impure.

f4 — reads mutable global config: config is a module-level dict. f4() with no arguments returns different values as config["mode"] changes. A pure function's output should depend only on its parameters; f4 has no parameters but returns different values.

The pattern: Any time a function reads from or writes to state that lives outside its parameters, it is potentially impure. Hidden impurities are especially dangerous because they look pure at the call site.

Expected Output
f1 calls: [5, 5, 5]
default arg mutated: True
f3 calls: [10, 20, 30]
f4 calls: ['A', 'A', 'A']
Hints

Hint 1: A mutable default argument (`def f(lst=[])`) is shared across all calls — the list is created once at function definition time, not per call.

Hint 2: Reading a global variable (without `global`) is a hidden side effect when that global can change between calls — the function is no longer deterministic.

#6Refactor OOP Method to Pure FunctionMedium
refactoringOOPpureimmutable-data

Refactor an impure OOP method to a pure function. The pure version should return the new account state without mutating the original.

Python
# Impure OOP version
class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        return self.balance

# Pure function version — takes and returns plain dicts
def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("Insufficient funds")
    return {**account, "balance": account["balance"] - amount}

# Test impure
acct = BankAccount(1000)
original_id = id(acct)
result = acct.withdraw(200)
print(f"original balance: 1000")
print(f"after impure withdraw: {acct.balance}")
print(f"account object mutated: {acct.balance == 800}")

# Test pure
account_dict = {"id": "acct_001", "balance": 1000, "currency": "USD"}
new_account = withdraw(account_dict, 300)

print(f"pure result balance: {new_account['balance']}")
print(f"original account unchanged: {account_dict['balance'] == 1000}")
Solution
original balance: 1000
after impure withdraw: 800
account object mutated: True
pure result balance: 700
original account unchanged: True

The core transformation:

The impure method BankAccount.withdraw mutates self.balance as a side effect. The caller's acct object is permanently changed; there is no way to get the previous balance back without tracking it externally.

The pure function withdraw(account, amount) uses {**account, "balance": ...} to create a shallow copy of the dict with the balance key overridden. The original dict is untouched. The caller receives a new dict representing the updated state and decides what to do with it (store it, discard it, compare with the original).

Benefits of the pure approach:

  • Undo/redo: Keep a history of old state dicts — free undo with no extra code.
  • Optimistic UI: Show the new state before committing to a backend; revert by switching back to the old dict.
  • Time-travel debugging: Log every state transition; replay any sequence of operations.
  • Concurrent reads: Multiple threads can read the old dict safely while a new one is being computed.
Expected Output
original balance: 1000
after impure withdraw: 800
account object mutated: True
pure result balance: 700
original account unchanged: True
Hints

Hint 1: In the pure version, do not modify `self` or any object passed in. Instead, return a new data structure (dict or namedtuple) representing the updated state.

Hint 2: The caller is responsible for updating their reference. The function just computes and returns the new state.

#7Memoization Only Works on Pure FunctionsMedium
memoizationpuritycachinglru_cacheside-effects

Demonstrate that memoization is safe for pure functions but produces wrong results for impure ones. Use functools.lru_cache and a call counter.

Python
import functools

# Pure function — safe to cache
pure_call_count = [0]

@functools.lru_cache(maxsize=None)
def fib(n):
    pure_call_count[0] += 1
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(f"pure fib(10): {fib(10)}")
print(f"pure fib(10) again (cached): {fib(10)}")
print(f"pure call count: {pure_call_count[0]}")

# Impure function — caching is WRONG
impure_real_calls = [0]
global_factor = [1]

@functools.lru_cache(maxsize=None)
def impure_multiply(x):
    impure_real_calls[0] += 1
    return x * global_factor[0]

# First call — factor is 1
result1 = impure_multiply(5)
print(f"impure cached call 1: {result1}")

# Change global factor — cache still returns old result!
global_factor[0] = 100
result2 = impure_multiply(5)
print(f"impure cached call 2: {result2}")
print(f"impure real call count: {impure_real_calls[0]}")
Solution
pure fib(10): 55
pure fib(10) again (cached): 55
pure call count: 11
impure cached call 1: 1
impure cached call 2: 1
impure real call count: 1

Why memoization requires purity:

fib(10) is called once and computes fib(0) through fib(10) — 11 unique calls. The second fib(10) hits the cache immediately (0 additional calls) and returns 55. This is correct because fib is deterministic — the same input always produces the same output.

impure_multiply(5) with global_factor[0] = 1 returns 5. The cache stores {(5,): 5}. When global_factor[0] changes to 100, the cache is unaware. impure_multiply(5) returns the stale cached value 5 instead of the correct 500. The real function body is called only once.

The rule: Memoization is a semantic-preserving optimisation if and only if the function is pure. Applying @lru_cache to an impure function introduces a correctness bug that may be very hard to detect in production — the function appears to work correctly until the hidden external state changes.

Expected Output
pure fib(10): 55
pure fib(10) again (cached): 55
pure call count: 11
impure cached call 1: 1
impure cached call 2: 1
impure real call count: 1
Hints

Hint 1: Apply `@lru_cache` to a pure Fibonacci function. Verify the call count drops on the second call to the same argument.

Hint 2: Show that caching an impure function (one that reads a changing global) produces stale results — the cached return value is returned even when the global has changed.

#8Pipeline of Pure TransformationsMedium
pipelinecompositionpuredata-transformation

Build a data-processing pipeline of four pure transformation functions and demonstrate that the original data is untouched after all transformations.

Python
# Pure transformation functions
def normalise(data):
    min_val = min(data)
    max_val = max(data)
    rng = max_val - min_val
    return [(x - min_val) / rng for x in data]

def filter_above(data, threshold):
    return [x for x in data if x >= threshold]

def scale(data, factor):
    return [x * factor for x in data]

def round_all(data):
    return [round(x) for x in data]

# Run the pipeline
original_data = [10, 20, 30, 40, 50]

step1 = normalise(original_data)
print(f"step 1 normalised: {step1}")

step2 = filter_above(step1, threshold=0.5)
print(f"step 2 filtered: {step2}")

step3 = scale(step2, factor=100)
print(f"step 3 scaled: {step3}")

step4 = round_all(step3)
print(f"step 4 rounded: {step4}")

print(f"original data unchanged: {original_data == [10, 20, 30, 40, 50]}")
Solution
step 1 normalised: [0.0, 0.25, 0.5, 0.75, 1.0]
step 2 filtered: [0.5, 0.75, 1.0]
step 3 scaled: [50.0, 75.0, 100.0]
step 4 rounded: [50, 75, 100]
original data unchanged: True

Pure function pipelines:

Each function returns a new list, never modifying its input. The data flows forward through the pipeline:

original_data -> normalise -> filter_above -> scale -> round_all -> result

Because all four functions are pure, the pipeline has several powerful properties:

  • Reorderable: you can move filter_above before normalise without worrying about shared state being corrupted.
  • Testable in isolation: each step can be tested independently with any input.
  • Parallelisable: if you had multiple datasets, you could run the pipeline on each concurrently — no shared mutable state.
  • Cacheable: any step could be memoised if the same input appears repeatedly.

original_data == [10, 20, 30, 40, 50] is True because no step ever called .sort(), .append(), or any in-place operation on the original list. Every list comprehension builds a brand-new list object.

Expected Output
step 1 normalised: [0.0, 0.25, 0.5, 0.75, 1.0]
step 2 filtered: [0.5, 0.75, 1.0]
step 3 scaled: [50.0, 75.0, 100.0]
step 4 rounded: [50, 75, 100]
original data unchanged: True
Hints

Hint 1: Each transformation takes data as input and returns new data without modifying the input. Chain them by passing the output of one as the input of the next.

Hint 2: Verify that `original_data` is unchanged after the full pipeline runs — this is guaranteed by purity.


Hard

#9Audit Trail via Pure State TransitionsHard
purestate-machineaudit-trailimmutable-state

Build a pure state machine for a bank account. Each transition is a pure function that returns a new state dict. Accumulate all states in a history list to enable free audit and replay.

Python
# Pure state transition functions
def open_account(state, initial_deposit):
    return {**state, "status": "active", "balance": initial_deposit}

def withdraw(state, amount):
    if state["status"] != "active":
        raise ValueError("Account not active")
    if amount > state["balance"]:
        raise ValueError("Insufficient funds")
    return {**state, "balance": state["balance"] - amount}

def close_account(state):
    return {**state, "status": "closed"}

# Run transitions and collect full history
initial_state = {"status": "pending", "balance": 0, "owner": "alice"}
history = [initial_state]

transitions = [
    lambda s: open_account(s, 1000),
    lambda s: withdraw(s, 200),
    lambda s: close_account(s),
]

for transition in transitions:
    history.append(transition(history[-1]))

for i, state in enumerate(history):
    print(f"state {i}: {state}")

print(f"history length: {len(history)}")

# Replay from state 1 (post-open) — apply withdraw + close
replayed = history[1]
replayed = withdraw(replayed, 200)
replayed = close_account(replayed)
print(f"can replay from state 1: {replayed == history[3]}")
Solution
state 0: {'status': 'pending', 'balance': 0, 'owner': 'alice'}
state 1: {'status': 'active', 'balance': 1000, 'owner': 'alice'}
state 2: {'status': 'active', 'balance': 800, 'owner': 'alice'}
state 3: {'status': 'closed', 'balance': 800, 'owner': 'alice'}
history length: 4
can replay from state 1: True

Pure state machines — how they work:

Each transition function takes a state dict and returns a new state dict using {**state, key: new_value}. The **state spread copies all existing fields, and the explicit key overrides just the changed fields. The original state is never touched.

Accumulating every state in history gives you a complete audit trail for free:

  • Undo: go back to history[i-1].
  • Time-travel debug: inspect any historical state.
  • Replay: take any snapshot from history and apply subsequent transitions to it. The result should be identical to the corresponding snapshot in history — this is the replay test at the end.

Why replay works: Because all transitions are pure, they are referentially transparent. withdraw(history[1], 200) will always produce the same result as history[2], no matter when or how many times you call it.

This pattern in production: Redux (React state management), event sourcing in DDD, and CQRS architectures all use this exact pattern — a sequence of pure state transitions that produce an immutable audit log.

Expected Output
state 0: {'status': 'pending', 'balance': 0, 'owner': 'alice'}
state 1: {'status': 'active', 'balance': 1000, 'owner': 'alice'}
state 2: {'status': 'active', 'balance': 800, 'owner': 'alice'}
state 3: {'status': 'closed', 'balance': 800, 'owner': 'alice'}
history length: 4
can replay from state 1: True
Hints

Hint 1: Each transition function takes the current state dict and returns a new state dict. Collect each returned state into a history list.

Hint 2: Replaying means applying the same sequence of transition functions to any snapshot. Starting from state 1 and applying withdraw + close should yield the same sequence.

#10Property-Based Testing of Pure FunctionsHard
property-basedtestingpureinvariantsrandom

Write property-based tests for pure functions. Test identity, associativity, and a round-trip (encode/decode) property using random inputs.

Python
import random

random.seed(42)

# Pure functions under test
def pure_add(a, b):
    return a + b

def encode_base64_simple(s):
    return s.encode("utf-8").hex()

def decode_base64_simple(h):
    return bytes.fromhex(h).decode("utf-8")

# Property-based test helpers
def test_property(name, predicate, num_cases=100):
    for _ in range(num_cases):
        if not predicate():
            return False, name
    return True, name

# Property 1: identity — adding 0 returns the same number
def identity_prop():
    a = random.randint(-1000, 1000)
    return pure_add(a, 0) == a

# Property 2: associativity — (a + b) + c == a + (b + c)
def assoc_prop():
    a = random.randint(-100, 100)
    b = random.randint(-100, 100)
    c = random.randint(-100, 100)
    return pure_add(pure_add(a, b), c) == pure_add(a, pure_add(b, c))

# Property 3: round-trip — decode(encode(s)) == s
def roundtrip_prop():
    chars = "abcdefghijklmnopqrstuvwxyz0123456789"
    s = "".join(random.choice(chars) for _ in range(random.randint(1, 20)))
    return decode_base64_simple(encode_base64_simple(s)) == s

all_passed = True
for prop_fn, label in [
    (identity_prop, "identity law"),
    (assoc_prop, "associativity law"),
    (roundtrip_prop, "round-trip law"),
]:
    passed, name = test_property(label, prop_fn)
    status = "PASS" if passed else "FAIL"
    all_passed = all_passed and passed
    print(f"{name}: {status} (100 cases)")

print(f"all properties hold: {all_passed}")
Solution
identity law: PASS (100 cases)
associativity law: PASS (100 cases)
round-trip law: PASS (100 cases)
all properties hold: True

Property-based testing vs example-based testing:

Example-based tests check specific inputs: assert pure_add(3, 4) == 7. Property-based tests check universal laws: for all a: pure_add(a, 0) == a. Properties catch edge cases you would never think to write as examples.

The three properties tested:

  1. Identity: pure_add(a, 0) == a — adding the identity element (0 for addition) returns the same value. Catches bugs like return a + b + 1 where the identity would fail.

  2. Associativity: (a + b) + c == a + (b + c) — the grouping of operations does not matter. Catches bugs involving order-of-operations errors.

  3. Round-trip: decode(encode(s)) == s — encoding and decoding are inverses. This property applies to any serialisation pair (JSON, pickle, base64, compression).

Why only pure functions can be property-tested reliably: Property tests generate random inputs and may run the function thousands of times. If the function has side effects, repeated calls corrupt external state, making results unreproducible. Pure functions can be called any number of times with the same inputs and always produce the same outputs, making large-scale random testing safe.

Expected Output
identity law: PASS (100 cases)
associativity law: PASS (100 cases)
round-trip law: PASS (100 cases)
all properties hold: True
Hints

Hint 1: A property-based test generates many random inputs and asserts that a mathematical property holds for all of them.

Hint 2: Test three properties: identity (adding 0 returns the same value), associativity ((a+b)+c == a+(b+c)), and round-trip (encode then decode recovers the original).

#11Extracting a Pure Core from a Legacy Impure FunctionHard
refactoringpure-coreimpure-shellfunctional-architecture

Extract a pure core from a legacy order-processing function. The pure core should contain all business logic; a thin impure shell handles I/O.

Python
# Pure core — all business logic, no I/O
def compute_order(user_id, items, discount_rate, tax_rate):
    subtotal = sum(item["qty"] * item["price"] for item in items)
    discount = round(subtotal * discount_rate, 2)
    taxable = subtotal - discount
    tax = round(taxable * tax_rate, 2)
    total = round(taxable + tax, 2)
    return {
        "user_id": user_id,
        "items": items,
        "subtotal": subtotal,
        "discount": discount,
        "tax": tax,
        "total": total,
        "status": "confirmed",
    }

# Impure shell — thin wrapper that would normally do DB write + logging
def process_order(user_id, items):
    # In production: fetch discount/tax from DB here (impure)
    discount_rate = 0.10
    tax_rate = 0.09

    # Delegate ALL computation to pure core
    result = compute_order(user_id, items, discount_rate, tax_rate)

    # In production: write result to DB, send confirmation email (impure)
    # db.save(result)
    # email.send(result)

    return result

# Test
order_items = [
    {"sku": "A", "qty": 2, "price": 10.0},
    {"sku": "B", "qty": 1, "price": 25.0},
]
original_items = [dict(i) for i in order_items]

result = process_order("u_001", order_items)
print(f"pure core result: {result}")

# Verify idempotency of the pure core
result2 = compute_order("u_001", order_items, 0.10, 0.09)
print(f"idempotent: {result == result2}")
print(f"original input unchanged: {order_items == original_items}")
Solution
pure core result: {'user_id': 'u_001', 'items': [{'sku': 'A', 'qty': 2, 'price': 10.0}, {'sku': 'B', 'qty': 1, 'price': 25.0}], 'subtotal': 45.0, 'discount': 4.5, 'tax': 4.05, 'total': 44.55, 'status': 'confirmed'}
idempotent: True
original input unchanged: True

The functional core, imperative shell pattern:

LayerWhat it containsTestable?
Pure core (compute_order)All business logic — pricing, discounts, taxYes — no mocks needed
Impure shell (process_order)I/O — DB fetch, DB write, email, loggingRequires mocks/integration tests

By pushing all computation into compute_order, the test surface for business logic requires zero infrastructure. You can test thousands of price/discount/tax scenarios in milliseconds without a database.

Idempotency verification: Calling compute_order twice with the same arguments returns the same dict — that is referential transparency in action.

original input unchanged: True: The function never mutates items. It only reads item["qty"] and item["price"] in a sum(). No append, no in-place modification.

Real-world impact: The Gary Bernhardt "Functional Core, Imperative Shell" talk (2012) popularised this pattern. Today it appears in every well-designed service layer: fetch data (impure), transform data (pure), persist results (impure). The pure transformation layer is the most valuable to test.

Expected Output
pure core result: {'user_id': 'u_001', 'items': [{'sku': 'A', 'qty': 2, 'price': 10.0}, {'sku': 'B', 'qty': 1, 'price': 25.0}], 'subtotal': 45.0, 'discount': 4.5, 'tax': 4.05, 'total': 44.55, 'status': 'confirmed'}
idempotent: True
original input unchanged: True
Hints

Hint 1: Move all computation into a pure `compute_order` function that takes the raw data and returns a dict. Leave only I/O (logging, DB writes, network) in the impure shell.

Hint 2: Test that calling `compute_order` twice with the same input returns the same dict — that verifies idempotency and purity in one assertion.

© 2026 EngineersOfAI. All rights reserved.