A pure function is a simple piece of code: it takes inputs, returns an output, and does nothing else. No printing, no file writes, no modifying its arguments, no reading hidden globals. Same input today, same output tomorrow. This lesson covers what purity means, the everyday Python patterns that break it, why pure code is easier to test and cache, and how to refactor a messy mix of math and I/O into a clean pure core wrapped by side effects at the edges.
A function is pure if it satisfies two conditions, both of which have to hold at the same time.
Rule 1: Same input, same output. Call the function twice with the same arguments. The return value must be the same both times. No randomness, no reading the clock, no fetching today's exchange rate from a server. The return value depends only on what was passed in.
Rule 2: No side effects. The function must not change anything visible outside itself. It can't mutate its arguments, can't update a global, can't write to a file, can't print to the console, can't send a network request, can't insert into a database. The only thing the world should see is the return value.
A short concrete pair shows the distinction.
discounted_price is pure. The output is fully determined by price and rate. Nothing outside the function changes when it runs. Three calls with the same arguments return the same value. The third call could be replaced with the literal 26.991 and the program would behave identically.
An impure version that looks almost the same:
The return value is consistent, but the function breaks Rule 2 because of the print. It also depends on TAX_RATE, a module-level global, which could change between calls. Both rules matter. Either one breaking is enough to make a function impure.
The technical term for a function that obeys both rules is referentially transparent: any call to the function can be replaced with its return value and the program's behavior doesn't change. Pure functions are the only ones for which that substitution is safe. It's the most important property functional code is trying to buy.
The diagram shows the shape of a pure function. Inputs flow in on the left. Output flows out on the right. The dashed line to the external world is the one that must stay absent: a pure function doesn't write anywhere outside itself. An arrow from the function back to globals, files, or its own arguments breaks purity.
Walking through a handful of small functions makes the categories concrete. The second column matters; the line between pure and impure isn't always obvious.
| Function | Pure? | Why |
|---|---|---|
def add(a, b): return a + b | Yes | Output depends only on args. Nothing changes outside. |
def square(n): return n * n | Yes | Same. |
def length(items): return len(items) | Yes | Reads a list but doesn't change it. Return value is a count. |
def shout(text): return text.upper() | Yes | str.upper() returns a new string. The original is unchanged. |
def add_item(cart, item): cart.append(item); return cart | No | Mutates cart, an argument. Side effect. |
def print_total(amount): print(amount); return amount | No | Prints. Side effect. |
def now(): return time.time() | No | Output changes every call. |
def random_discount(): return random.random() | No | Different output each call. |
def lookup(key): return CACHE[key] | No | Behavior depends on global state. Returning CACHE[key] can differ across calls. |
def save(record): db.insert(record); return record["id"] | No | Database write. Side effect. |
def read_config(): return open("config.txt").read() | No | File I/O. Side effect (the file system can change between calls). |
A few of these deserve a closer look because they're often misjudged.
Mutating an argument:
The caller passed in shopping. After the call, shopping has changed. That's a side effect even though the function "looks like" it returns a value. The return value is the same object the caller already had a reference to, which is another red flag. A pure version returns a new list:
Now shopping is unchanged, and the returned list is a brand-new object. The caller can choose whether to overwrite shopping = add_item(shopping, "keyboard") or keep both versions.
Reading a global:
This function looks innocent. It doesn't write anywhere and it doesn't mutate. But its output depends on TAX_RATE, which is outside the function's arguments. If someone changes TAX_RATE between two calls, the function returns different answers for the same input. That breaks Rule 1. The fix is to pass the rate in:
Now everything with_tax needs comes through the argument list. Behavior is fully determined by what the caller passes.
Reading the clock or random state:
Same input (no arguments) gives different outputs. Rule 1 is broken. time.time() and random.random() are both classic sources of impurity. They're not wrong to use; some functions need to know what time it is. They're just not pure, and they shouldn't be treated as if they were.
Calling time.time() or random.random() is cheap in isolation. The cost of these calls isn't CPU; it's that they make tests harder to write and cache annotations like @lru_cache incorrect, because every "miss" with the same argument can produce a different value.
Reading a file:
This depends on something outside its arguments (the file on disk), and the file can change between calls. Even if no one changes the file, the function depends on the file system existing and having that file. That's not the kind of "determined only by arguments" purity requires.
The reason functional code goes to so much trouble for purity is that pure functions provide three concrete things that impure functions can't.
Testable in one line. A pure function is easy to test. Hand it an input, compare the output, and that's the whole test. No setup, no teardown, no mocks, no fixtures, no spinning up a database or a fake server.
Four assert statements, four cases covered. No setUp. No mock.patch. Compare with testing an impure version that reads TAX_RATE from a module-level global: the test would need to set it before, reset it after, and worry about other tests changing it. Pure functions make tests cheap, which encourages writing more of them, which catches more bugs.
Parallelizable without fear. A function that doesn't share state with anything else can run thousands of calls in parallel and produce the same result as running them one at a time. There's no lock to take, no race condition to debug, no shared mutable state for two threads to corrupt at the same time. Python's concurrent.futures.ProcessPoolExecutor exists for this pattern.
apply_discount is pure, so handing it out across multiple processes is safe. Each worker gets its own copy of the input, runs the function, returns the output. Nothing shared, nothing to coordinate. This pattern falls apart the moment a "worker" function mutates a global, writes to a file, or expects to share state with the parent process.
Cacheable with `@lru_cache`. A pure function with the same input always returns the same output, so the answer can be remembered once and the computation skipped next time. Python's functools.lru_cache decorator handles this with one line.
Without the cache, fibonacci(50) recurses billions of times. With it, each value is computed once and reused, so the call finishes instantly. The technique only works because fibonacci is pure: the cache assumes that if fibonacci(20) has already been computed, the answer is still good the next time it's needed. Tagging an impure function with @lru_cache will give wrong answers.
lru_cache trades memory for speed. It keeps every (args, result) pair until maxsize is reached, then evicts the least recently used entries. Use maxsize=None only when the input space is small enough to fit in memory; otherwise pass an explicit limit like lru_cache(maxsize=1024).
The general name for this trick is memoization: caching the results of a pure function so repeated calls with the same arguments return instantly. It's one of the most practical day-to-day wins from writing pure code.
The diagram is the memoization flow lru_cache implements. A call comes in. If those arguments have been seen before, return the stored answer. Otherwise run the function, store the answer, and return it. The cache is only safe because the function is pure; an impure function could return a different value the second time, but the cache would still return the first one.
Real programs need side effects somewhere. They read user input, write to files, talk to databases, render UIs. Purity isn't about avoiding side effects entirely; it's about pushing them to the edges of the program and keeping the core logic pure.
The standard recipe is "functional core, imperative shell": a thin outer layer that handles I/O and orchestration, calling into a thick inner layer of pure functions that do the actual thinking. The pure core is easy to test, easy to reason about, and easy to reuse. The shell is the one place where the messy real-world interactions happen.
A small concrete refactor. Start with a tangled function that does everything in one go:
This is fine, but it mixes several concerns: reading a file, deciding whether to process, computing totals, writing the file back, and printing a confirmation. Testing it requires either an actual file or a mock. Reusing the math somewhere else (say, an admin dashboard) means copy-pasting the middle of the function.
Refactor by pulling the pure pieces out:
The arithmetic and the decision logic now live in calculate_totals and should_process. Both are pure: they take dicts, return values, mutate nothing, and read nothing outside their arguments. Both are easy to test.
Three observations. First, the test runs without touching a file. Second, the original sample_order still has status == "placed" at the end of the test, because calculate_totals returned a new dict instead of mutating the one passed in. Third, the test is four assertions long. That's the kind of test pure code makes possible.
The general moves when refactoring toward purity:
After applying these moves, the usual result is a core of pure helpers and a thin layer of imperative orchestration. That's the goal.
The diagram is the "functional core, imperative shell" shape. The orange shell handles the world's mess: files, network, time, randomness. The green core does the actual thinking with pure functions. Data flows in from the shell as arguments, and new values flow back out as return values. The core never reaches up into the shell; the shell does all the wiring.
Three traps can affect Python code aiming for pure functions. They all hide well.
Pitfall 1: Mutating arguments while "returning a new value."
The function "returns the cart" but the cart it returns is the exact same list the caller passed in, with its dictionaries mutated in place. original now has the discounted prices, even though it was never reassigned. This is the most common form of accidental impurity: the function looks like it returns a result, but it also modifies the input.
The fix is to build new dictionaries:
Now original keeps its original prices and discounted is a separate list of new dicts.
Pitfall 2: Reading a closure variable that gets mutated.
with_tax doesn't take the rate as an argument; it reads it from config, which lives in the enclosing scope. Same input, two different outputs, depending on what happened to config between calls. The function isn't pure even though it doesn't mutate anything itself.
The fix is again to pass the rate in:
A useful rule of thumb: if a function needs information that isn't in its arguments, pull that information into an argument. Anything else makes the function depend on external state.
Pitfall 3: Confusing "no obvious side effect" with "pure."
A function can avoid every printable side effect and still be impure if its output isn't deterministic. The most common version uses random or time inside the function.
Same input, different output. The function doesn't print, doesn't mutate anything, doesn't touch a file. It's still not pure, because of Rule 1. The fix is to take the random rate as an argument:
The randomness still happens; it just happens at the caller, not inside the calculation. The calculation is now pure and the impure choice is isolated.
Programs aren't pure. They have to read input, write output, and talk to the outside world. The goal of functional thinking isn't to make every function pure; it's to make as much of the logic pure as possible, and isolate the impurity to specific places.
A typical Python program has a few categories of code:
A useful image is a triangle where the pure parts are the wide base (most of the code) and the impure parts are the narrow tip (a thin layer at the boundary). When the impure tip starts growing into the middle of the triangle, that's the smell to refactor against.
Pure functions don't have to be everywhere, and forcing them where they don't fit makes the code worse, not better. A function whose entire job is "log this event" is meant to have a side effect. A function whose entire job is "save this order" is meant to write to the database. Don't twist those into something they aren't.
The functional discipline is not "no side effects." It's: side effects happen, and they happen in specific, identifiable places. That's the practical goal.
10 quizzes