Percentage change is four characters of arithmetic: (new - old) / old. I have shipped bugs in it five distinct ways. Every one of them made it through review, because the code looks obviously correct.
Here they are, in the order I learned them the hard way.
1. The zero baseline
const change = (current - previous) / previous;
Ship this and it works for months. Then a new customer signs up, their first month has previous = 0, and your dashboard renders Infinity%. Or NaN% if current is also zero.
There is no right answer here — the percentage change from zero is genuinely undefined, not "100%" and not "0%". The bug isn't the maths, it's pretending the maths has an answer:
function percentChange(previous, current) {
if (previous === 0) {
return current === 0
? { kind: "unchanged" }
: { kind: "new", absolute: current }; // "New" beats a fake percentage
}
return { kind: "change", value: (current - previous) / previous };
}
Every analytics product you've used has solved this by showing "New" or "—" instead of a number. That's not a cop-out; it's the correct representation.
2. Percent vs percentage points
Conversion rate goes from 2% to 3%. Your dashboard says "+1%".
It's wrong, and it's the kind of wrong that ends up in a board deck. The rate rose by 1 percentage point, which is a 50% increase. Those are different numbers and both are legitimate — but only one of them is what your label claims.
// Comparing two values that are THEMSELVES percentages
const pointDelta = newRate - oldRate; // 1 → "percentage points"
const relative = (newRate - oldRate) / oldRate; // 0.5 → "percent"
My rule now: if the underlying quantity is a rate, the UI must say "pp" or spell out "percentage points". A reviewer once caught this in a churn dashboard where 4% → 5% was being reported as "+1% churn" — leadership read it as basically flat, when churn had actually risen by a quarter.
3. Symmetry that isn't
Down 50%, then up 50%, does not get you back.
100 * 0.5 = 50
50 * 1.5 = 75 // not 100
To recover from a 50% drop you need +100%. From a 20% drop, +25%. The recovery percentage is 1/(1-d) - 1, and it grows fast — a 90% drop needs a 900% gain.
This one bites in tests more than in production. I've seen a test written as "apply -30%, apply +30%, assert original value" that passed only because the assertion used a loose tolerance. The tolerance was hiding a genuine sign error elsewhere in the function.
4. Averaging percentage changes
Three months of growth: +10%, -20%, +30%. What was the average monthly growth?
The arithmetic mean says +6.7%. Compound the actual numbers and you get:
1.10 * 0.80 * 1.30 = 1.144 // +14.4% total over three months
Math.pow(1.144, 1/3) - 1 // ≈ 4.6% per month, not 6.7%
Percentage changes multiply, so their average is the geometric mean, not the arithmetic one. The arithmetic mean overstates growth every time the values vary — and the more volatile the series, the bigger the overstatement.
function averageGrowth(rates) { // rates as decimals: [0.10, -0.20, 0.30]
const product = rates.reduce((acc, r) => acc * (1 + r), 1);
return Math.pow(product, 1 / rates.length) - 1;
}
I found this one in a report that had been going out weekly for half a year. Nobody noticed, because the number was plausible. Plausible-but-wrong is the worst failure mode in reporting code.
5. Negative baselines
percentChange(-100, -50) // (−50 − −100) / −100 = 50 / −100 = −0.5
Profit improved from −$100 to −$50, and the function reports a 50% decrease. The sign flips because dividing by a negative baseline inverts the whole thing.
There is no clean fix, because "percentage change" isn't meaningful when the quantity can cross zero — what would a 200% change from −$50 even mean? For anything that can go negative (profit, net flow, temperature deltas), report the absolute change and skip the percentage:
if (previous <= 0) return { kind: "absolute", delta: current - previous };
This is the one I'd most want a reviewer to catch, because it doesn't crash, doesn't look odd, and reports improvements as regressions.
The pattern
Four of these five aren't arithmetic errors. They're presenting a number in a context where it doesn't mean what the label says. The formula was right every time; the framing was wrong.
Which is why I now treat percentage change as a function that returns a tagged union rather than a float. If the caller has to handle "new" and "absolute" cases explicitly, they can't accidentally render a meaningless percentage — the type system asks the question the reviewer forgot to.
If you want the underlying maths laid out properly, including why percentage difference (symmetric, divides by the mean) is a different formula from percentage change and when you'd want it, MathIsSimple walks through it with worked examples.
Curious whether anyone has hit a sixth. The negative-baseline one took me two years to notice, so I assume there are more waiting.
Top comments (0)