Most articles about pure functions read like recruitment material for functional programming. "Pure functions are easier to reason about, easier to test, and easier to parallelize." All true, all incomplete. There are real costs, and pretending otherwise is how teams end up with codebases that copy fifteen-thousand-element arrays on every state update because someone read a Medium post about immutability and forgot to read the next one about performance.
My stance for this article: pure functions and immutable data are correct defaults for most application logic, but they are defaults, not absolutes. The cost is real, the cases where mutation pays off are real, and an engineer who cannot tell you the trade-off in their own words is going to make the wrong call when it matters. I want to write the honest version of the comparison, with the wins, the costs, and the rules I follow when one approach beats the other.
What "pure" actually requires
A function is pure if calling it with the same arguments always returns the same value, and calling it has no observable side effects. That is two clauses, both load-bearing. The first rules out reading from a database, asking for the current time, calling Math.random, or reading a mutable global. The second rules out writing to a database, mutating an argument, sending an email, logging.
A function I think people often miscall pure:
This is not pure. Two calls with 'user' return different values. The randomness is the impurity, even though the function does not write anywhere visible. The fix, if you want this in your pure layer, is to take the random source as an argument: newId('user', randomFn). Now testing it is trivial because you can pass a deterministic randomFn from the test.
A function I think people often misclass as impure:
This IS pure, even though it has internal mutation. The mutation is local. It does not escape the function. The output is determined by the input. "Pure" is about external observability, not internal style.
Why the wins are real
Three wins I have measured, not just hoped for:
- Tests are smaller and more reliable. A pure function's test is one input, one expected output. No setup, no mocks, no time control, no database. That is dramatically less code per test. In a service I worked on we measured the test pyramid before and after migrating the decision-making logic to pure functions: the unit tests grew by 40% in count, the integration tests shrank by 60% in count, and the test suite as a whole ran 4x faster.
- Concurrent code is honest. Two concurrent reads of an immutable value cannot conflict. Two concurrent reads of a mutable value can conflict, even if you think they cannot. JavaScript's single-threaded event loop hides this, but the moment you introduce web workers, Node
worker_threads, or any genuinely parallel runtime, immutability is the property that lets you not think about locks. - Refactors are local. A pure function with no callers that mutate it can be moved between files, renamed, or replaced by another pure function with the same signature, and the rest of the codebase does not have to be re-read. Mutation creates implicit contracts ("who is allowed to write to this object") that refactors must preserve.
The testability point is the one I find dominant in practice. The biggest waste-of-time activity I have measured in engineering teams is fixing flaky tests, and the second biggest is writing tests with extensive mock setup. Pure functions reduce both, in proportion to how much of the codebase is pure.
Why the costs are also real
Three costs I have learned to respect:
The first cost is allocation pressure. Immutable data structures, naively implemented, allocate a new copy on every "update". For a small object (a few fields), this is free. For a large array or map, it is not. JavaScript's [...arr, item] allocates a new array and copies every element of the old one. For a thousand-item array, this is microseconds; for a million-item array, this is milliseconds, and inside a tight loop it is real. Tools like Immer, Immutable.js, and persistent data structures (like the ones Clojure uses) reduce this cost but do not eliminate it.
The second cost is ergonomic friction in some languages. JavaScript and Python make immutability awkward (Object.freeze is shallow, no real immutable types in the standard library). Java made it better with record classes; Kotlin and Swift made it better still; Rust makes it the default. The friction is not the same in every language. A team writing TypeScript will pay more for the immutability discipline than a team writing Rust, because the ergonomic helpers are weaker.
The third cost is mental overhead in some domains. There are problems where mutation is the natural expression. Graph algorithms, parsers, simulations with thousands of moving entities, hot loops in game code. Forcing those to use immutable updates is sometimes possible but always slower and usually less clear. If you are writing a path-finder over a hundred-thousand-node graph, allocating a fresh visited-set on every step is wrong even if it is pure.
When mutation is the right answer
Four cases I would defend in a code review:
Three of those cases live inside a function. The fourth (build-then-freeze) crosses the function boundary, but the mutation is bounded: the object is mutable while it is being built and immutable thereafter. This is the pattern Rust uses for many of its standard-library builders.
The case I would NOT defend is mutation across module boundaries. A function that takes an array and mutates it, returning nothing or returning the mutated array, is the most common source of "why does my data look weird" bugs in long-lived JS codebases. The cost of those bugs is high; the cost of avoiding them by copying is low. The default should be don't mutate things you did not allocate.
The middle ground I actually use
Most of my code lives in a hybrid mode I would describe as: pure where possible, immutable across module boundaries, mutation allowed inside small scoped helpers. In TypeScript that looks like:
The outer caller sees an immutable input and immutable output. The inner implementation does whatever it needs to do efficiently. The mutation is invisible from outside. This is the build-then-return pattern; it is honest about "pure" because the externally observable behavior is pure, even though the internals are not.
A thing I push back on: code that uses spread-based immutable updates inside a tight loop because someone read that immutability is good. acc = { ...acc, [key]: value } inside a loop allocates and copies the entire accumulator on every iteration. For small accumulators this is free; for big ones, it is O(n^2) in disguise and I have seen it cause real production performance regressions. The rule: at the call boundary, immutable; inside the loop, mutate the local accumulator.
Read-only types as a halfway house
A pattern I lean on heavily in TypeScript: Readonly<T> and ReadonlyArray<T>. These make mutation a type error rather than a runtime error. They cost nothing at runtime (TypeScript erases them), and they catch the most common mutation mistakes during development.
This is the cheapest way to enforce a pure-style contract in a language that does not have first-class immutable types. I make every function accept Readonly<T> for its input parameters by default; the only exceptions are local-mutation cases I explicitly want, and the function comment usually says "WARNING: mutates acc" when that is the case.
Where "immutable everywhere" is genuinely wrong
I want to be specific about the cases where the immutability dogma falls down.
Long-running React state with deeply nested mutations through Immer-style helpers is fine, but if you find yourself doing setState(s => deepClone(s)) to force a re-render, your data model is wrong. The fix is not more cloning; it is breaking the state into smaller pieces with their own identities.
In Python, list comprehensions and generator expressions are pure-functional in flavor. They are also faster than equivalent map/filter chains and dramatically faster than "create a new list, append to it, return it" loops. Mutation inside a comprehension body ([do_thing() for x in xs] where do_thing mutates state) is the wrong pattern; pure comprehensions are right.
In Go, the language genuinely does not push you toward immutability and the conventional code is mutation-heavy. Pretending otherwise leads to non-idiomatic Go that the rest of the team can't read. I follow the language's grain there: I keep my decisions pure (no time.Now(), no global state) and I let the imperative parts mutate.
A two-question rule
When I am about to write a function in a codebase I work in, I ask two questions in order:
- Can this function be pure? Pure meaning: same inputs, same outputs, no side effects. If yes, write it that way. The cost is small, the benefit compounds.
- If not, can the inputs and outputs at least be immutable? If yes, accept
Readonly<T>and return a new value. The function is still impure (it might log, or read time, or do I/O), but its arguments are not at risk.
If both answers are no, I write the impure mutating function. There are cases for it. I do not feel guilty about it. I just confine it to the smallest scope I can and document the mutation it performs.
The honest closing position
Pure functions and immutability buy testability, parallelism, and refactoring confidence. They cost allocation, ergonomic friction in some languages, and mental overhead in some domains. The right ratio in a codebase is not 100% pure or 0% pure; it is the ratio where the wins are larger than the costs, which depends on the language, the domain, and the team. For most application logic in TypeScript, Python, Java, Kotlin, or C# in 2026, that ratio is high (probably eighty percent of decision logic should be pure). For systems code, game code, and tight numerical loops, the ratio is lower. Knowing which kind of code you are writing, and being honest about which trade-off applies, is more valuable than the general principle.
