Side effects, and why some functions are easy to test
Two kinds of function
def with_tax(amount, rate=0.18):
return round(amount * (1 + rate), 2)Given the same arguments, this returns the same answer every time, and calling it changes nothing anywhere else. That is a pure function.
def save_invoice(invoice):
with open("invoices.csv", "a") as f:
f.write(invoice.as_row())
logger.info("saved")This one has side effects: it touches a file and a log. Call it twice and the world is different than if you called it once.
Both kinds are necessary. A program of only pure functions cannot read input or show output, so it does nothing observable. The point is not to eliminate side effects; it is to know which functions have them.
What counts as a side effect
- Reading or writing a file, a database, a network
- Printing, or logging
- Changing a global, or a module-level cache
- Mutating an argument — the quiet one
- Reading the clock, or a random number, or an environment variable
That last group makes a function impure without changing anything, because the result depends on something other than its arguments. def is_expired(date): return date < datetime.now() cannot be tested for a fixed answer, and the test that passes today fails next year.
The fix is to pass the dependency in:
def is_expired(date, now):
return date < nowNow the test is one line, the production call is is_expired(d, datetime.now()), and the impurity has moved to the caller where it is visible. The same trick handles randomness — pass a seeded generator — and configuration.
Why this matters more with AI in the picture
A call to a language model is impure in the strongest way: same input, different output, at a cost, over a network that can fail. Mix it into a function that also parses, validates and formats, and you have code that cannot be tested without spending money and cannot be debugged because you can never reproduce the run.
The shape that works:
def build_prompt(question, context): # pure
...
def parse_reply(text): # pure
...
def answer(question, context, call_model): # impure only in the argument
reply = call_model(build_prompt(question, context))
return parse_reply(reply)build_prompt and parse_reply get tested exhaustively for nothing. answer gets tested with a fake call_model that returns a fixed string. The real model appears once, at the edge of the program. Module four returns to this when it covers testing code that calls an API.
Mutating what you were given
def normalise(rows):
for r in rows:
r["name"] = r["name"].strip().title()
return rowsThis looks pure — it takes rows and returns rows — and it has quietly rewritten the caller's data. Every later use of the original now sees the modified version, including code that wanted the raw values.
Two honest designs:
def normalised(rows): # returns new, leaves input alone
return [{**r, "name": r["name"].strip().title()} for r in rows]
def normalise_in_place(rows) -> None: # mutates, returns nothing
for r in rows:
r["name"] = r["name"].strip().title()Name and return type together tell the caller which one they have. The version that mutates and returns is the one that causes trouble, because it invites clean = normalise(rows) and the reader assumes rows is untouched.
A shape for whole programs
The pattern that scales is a pure core with an effectful shell:
- The outer layer reads arguments, files and network responses.
- The middle is pure: parsing, calculation, formatting, decisions.
- The outer layer writes the results.
Most of the interesting logic ends up in the middle, where it is testable without a database, a network or a clock. When a program is hard to test, it is nearly always because a decision is buried inside a function that is also doing I/O.
Where purity is a false economy
Do not thread a database connection through nine layers of arguments to keep a function pure. Do not rebuild a large list on every call to avoid mutation when the profiler shows it costs a second. Purity is a tool for testability and reasoning, not a rule to be satisfied. The realistic goal is that every function has a side effect you could name in a sentence, or none at all — and that you know which.
The one thing to keep
Functions that read the clock, the network or a model are impure, so pass those dependencies in as arguments and keep the parsing, calculation and formatting pure enough to test for free.
Before you move on
A summariser is one function that reads a file, calls a language model, parses the reply and writes a report. It fails intermittently in production and cannot be reproduced. Which change most improves the situation?
Pick the one you would defend. Nobody sees your answer.