← back

Money is not a float

Why 0.1 + 0.2 never quite equals 0.3, and why that matters a lot when the number is money.

Open a JavaScript console and type 0.1 + 0.2. You’ll get 0.30000000000000004. Screenshot it. Frame it. That extra 00000004 has caused more month-end pain than any human error I have ever caught, and I have caught some genuinely impressive human errors.

The annoying part is that this isn’t a bug. The float is doing exactly what it was designed to do: approximate, fast. It’s a base-2 number, and most decimal fractions have no exact base-2 form — same reason you can’t write one-third in decimal without a pencil and a lot of stamina. 0.1 in binary repeats forever, so the computer rounds it off somewhere and hopes nobody looks.

Somebody always looks. Usually an auditor. Usually at the worst possible hour.

The horror story from my own ledger

Here’s the one that shortened my life. Postgres will happily store the string NaN inside a NUMERIC column — the one type that exists specifically to hold money exactly — if you ask it politely. And decimal.js will parse a non-finite value and pass it through without a murmur. So a number that was never a number walks into the ledger, and every total it touches downstream becomes exactly as fictional as it is.

The fix is one function, and it’s the only door into the system:

export function d(v: Numeric): Decimal {
  const x = new Decimal(v);
  if (!x.isFinite()) throw new RangeError(`Non-finite number rejected: ${v}`);
  return x;
}

Every quantity and every amount passes through it. Nothing is allowed to be infinite, undefined, or not-a-number. A ledger with NaN in it isn’t slightly wrong, it’s art.

Why the drift is worse than it looks

One rounding slip is a footnote. Three billionths of a cent on one line, nobody cares. The problem is accumulation:

  1. Every transaction adds another tiny slip. Thousands of lines later, the slips are a number.
  2. The slips don’t cancel. They lean in a direction, and after enough transactions the lean becomes visible.
  3. When reconciliation fails over it, nobody accepts “the computer rounded” as a cause. Because it isn’t a cause. It’s a theme.

I chased one of these for an evening once — a small mismatch that kept reappearing between the layer table and the stock account. Every step of the math was “correct”. Two exact operations performed on approximated inputs, which produces an output that is exactly wrong in ways no calculator will ever reproduce for you.

The rule accountants already know

This one predates computers: the smallest monetary unit is indivisible, so store money in that unit. Cents. Rupiah sen, if anyone still admits sen exists. Integers don’t drift:

// float: 0.30000000000000004
const price = 0.1 + 0.2;

// integer cents: exactly 30
const price_cents = 10 + 20;

Divide only at display time, and only to format. Everything between the door and the journal stays exact.

When cents aren’t enough precision — unit costs after landed-cost allocation, per-length material rates — the answer is still not a float. CostLayer runs decimal arithmetic at 28 significant digits with half-up rounding, quantities at 4 decimal places, unit costs at 6. Not because I love precision. Because rounding early is how a $48.250768 stool quietly becomes a $48.25 stool and takes the difference with it.

The rounding that is allowed happens exactly once, when the journal posts, and the rounding difference lands on its own account line where everyone can see it. Nothing hides in the middle.

The rules, kept like a swear jar

  • Floats and money: never. Not once, not in a helper, not “just for this display logic, I’ll clean it up after”. You won’t clean it up after. You never clean it up after.
  • Integer cents or a decimal type. Pick one and carry it through the entire pipeline.
  • Aggregation is exact. Only the human-facing display rounds, and it goes last.
  • If a number can’t be represented exactly, don’t present it as if it is. Round it deliberately, book the difference, say so out loud.

You’re not allowed to be mostly right about money. “Mostly” is what variance is made of, variance is what month-ends are made of, and I have already donated enough of my life to both. The next person who ships float math into a ledger can explain the difference to the auditor personally — I’ll bring the console screenshot.