0.1 Plus 0.2 Is Not 0.3, and Your Checkout Noticed

Share
0.1 Plus 0.2 Is Not 0.3, and Your Checkout Noticed. Abstract deep dive illustration in orange and dark grey on debugly.dev

The reconciliation was off by a few cents on some orders, and the cents did not round to anything explainable. A discount of ten percent on a price of 19.99, applied, summed and compared to the expected total, produced a value that was wrong in the third decimal, which no currency has, and which no display showed, but which the equality check in the payment verification caught. The money was stored as a JavaScript number, and a JavaScript number is a binary double, and a binary double cannot hold 19.99.

This is not a curiosity. It is the most expensive sentence in this post: the value 19.99 does not exist in the representation. The nearest double is stored, and every arithmetic on it carries the ghost of the difference, and money is the domain where the ghost is audited.

This was a Node 22.14 checkout doing percentage discounts and multi item sums in doubles, and the findings apply to any language using binary floating point for currency, which is most of them by default.

Why the representation fails

A double stores a number as a binary fraction, and most decimal fractions, including a tenth, have no finite binary expansion, exactly as a third has no finite decimal expansion. So 0.1 is stored as the nearest representable binary value, slightly off, and 0.2 likewise, and their sum is the sum of two approximations, which is not the approximation of 0.3. The famous REPL output is not a bug in the language. It is the representation being honest.

Currency in decimal units is therefore always approximated in a double, and the approximation is bounded but nonzero, and arithmetic composes it. A sum of many items, a percentage discount, a tax rate, a currency conversion, each step rounds to the nearest representable value, and the roundings accumulate. Per transaction the error is sub cent and invisible. Across the reconciliation, summed over thousands of orders, it is a number with a name and an owner.

Why rounding at display does not save you

The standard defence is to round at display, and it fails in the specific places money gets checked rather than shown.

Equality checks, like the payment verification that compares the computed total to the gateway's total, compare the unrounded or differently rounded values, and disagree by the accumulated ghost. The gateway rounded per line, you rounded per order, and the two roundings are different values.

Accumulation across records, like summing line totals to an order total, or order totals to a payout, compounds the per record ghosts, and the sum's rounding does not equal the sum of the roundings, which is exactly the reconciliation discrepancy.

And persistence, where a value written as a double and read back is the double, so the ghost survives storage and is audited later as if it were a decision.

The integer minor unit model

The durable model is to represent money as an integer count of minor units, cents, pence, and to do all storage, transport and most arithmetic in that integer. Nineteen point nine nine is 1999, exactly, in an integer, with no representation error, and sums of integers are exact, and the only division that is inexact is the percentage, which is handled by a defined rounding rule at the one place it occurs.

The rounding rule is the policy part: round half up or banker's rounding, applied at the discount or tax computation, producing an integer, so that every stored and compared value is exact and the one inexact operation is explicit, reviewed and tested. The gateway and the ledger then agree because both hold integers and the single rounding is a named line of code rather than a property of the representation.

For amounts beyond the integer range or for fractional rates, a decimal type with a fixed scale is the equivalent in languages that have it, and in JavaScript a small money abstraction over bigint plays the role, with the scale fixed and the arithmetic confined to the abstraction so that raw doubles never touch money again.

The conversion layer

The boundary where doubles are legitimate is the conversion from human decimal input to minor units, and it is the one place to be careful: parse the decimal string directly into minor units, never through a float, because parsing 19.99 through a double and then multiplying reintroduces the ghost at the door. Parse the string, split at the decimal point, build the integer from the digits, and the input is exact.

Currency conversion, which genuinely produces fractional minor units, is the operation that must round by policy, and the policy must say where the remainder goes, because a conversion that drops the fraction on every line is a systematic leak, small and directional, which is the worst shape of error, the kind that accumulates toward someone.

The tests that catch it

The money module's tests should be the decimal horrors: the tenths sum, the percentage of a repeating price, the sum of many lines versus the rounded total, the conversion remainder over a thousand iterations, asserted as exact integer equalities against hand computed values. These are the tests that fail when a double sneaks back in, and they are cheap, and they are the regression net for the abstraction's boundary.

The rule

Binary floating point cannot represent decimal currency, and the error it introduces is invisible per transaction and audited in aggregate. Store and transport money as integer minor units, confine the single inexact operation to a named, policy rounded line, parse decimal input without touching a float, and test the decimal horrors as exact integer equalities.

The checkout that holds money in doubles is not occasionally wrong. It is always approximating, and the reconciliation is simply the day the approximation gets a witness. The same "the representation silently decides the outcome" lesson appears in identifiers, in what your incrementing order IDs tell your competitors, where the default encoding carried information nobody chose to disclose.