Every codebase I have inherited has had this bug somewhere. Usually in a totals column, occasionally in a payments table, once in a payroll system that had been quietly losing about forty dollars a month for three years.
0.1+0.2
// 0.30000000000000004
0.1+0.2===0.3
// false
That is not a JavaScript quirk. It is IEEE 754, and it behaves the same way in Python, Java, C, Go, Rust and everything else using hardware floating point.
Why It Happens
Binary cannot represent most decimal fractions exactly, for the same reason decimal cannot represent one third exactly. Write 1/3 in decimal and you get 0.333... forever. Write 1/10 in binary and you get 0.0001100110011... forever.
Double precision gives you 53 bits of mantissa, so that infinite expansion gets truncated. The nearest double to 0.1 is actually:
0.1000000000000000055511151231257827021181583404541015625
Close enough for a physics simulation. Not close enough for an invoice. The Floating Point Guide is the best short explanation of the underlying representation if you want the full picture.
The practical consequence is that every arithmetic operation on a decimal value introduces a small error, and those errors do not cancel out.
letbalance=0;
for(leti=0;i<10;i++)balance+=0.1;
balance// 0.9999999999999999
balance===1// false
Ten additions. Already wrong.
The Part That Actually Ruins Your Week
Accumulated drift is annoying. Order dependence is disqualifying.
Floating-point addition is not associative. The same three numbers, added in a different sequence, give a different answer:
(0.1+0.2)+0.3// 0.6000000000000001
0.1+(0.2+0.3)// 0.6
This matters more than the size of the error, because it means your totals depend on evaluation order. Change a query plan, parallelise a reduction, let two threads interleave differently, and the same ledger produces a different sum. Your reconciliation job now fails intermittently and nobody can reproduce it.
The domain that punishes this hardest is regulated gaming, because an external party re-runs your arithmetic before you are allowed to ship. Return-to-player figures on Australian online pokies are published to two decimal places and verified by independent test houses against simulation runs in the tens of millions of rounds. The interesting part is that float error is not the problem there. Over ten million additions of values near 1.0, typical double-precision drift sits around the twelfth decimal place, nowhere near two. The problem is reproducibility: a certification lab needs the same inputs to yield byte-identical outputs, and non-associative addition means a parallel sum cannot promise that. Systems that cannot reproduce their own numbers cannot be audited, regardless of how small the discrepancy is.
Reconciliation has the same requirement for less dramatic reasons. If your ledger entries must sum exactly to a stored balance, "approximately" is a failed check.
Fix One: Integer Minor Units
Store money as an integer count of the smallest unit. Cents, not dollars.
constpriceCents=1099;// $10.99
constqty=3;
consttotalCents=priceCents*qty;// 3297 → $32.97
Addition, subtraction and multiplication by integers are now exact. This is what most payment APIs do, and it is why Stripe amounts are integers.
Format at the boundary, never in the middle:
constfmt=(cents)=>
newIntl.NumberFormat('en-AU',{
style:'currency',currency:'AUD'
}).format(cents/100);
fmt(3297);// "$32.97"
One caution. In JavaScript, integers are exact only up to Number.MAX_SAFE_INTEGER:
Number.MAX_SAFE_INTEGER// 9007199254740991
That is roughly ninety trillion dollars in cents, which is fine until you are dealing in a currency with a small unit value or aggregating across a very large book. Use BigInt if you might approach it.
In the database, pick the column type deliberately. Never REAL or DOUBLE PRECISION for money. Postgres numeric types are exact, and BIGINT for minor units works too:
-- no
balanceDOUBLEPRECISION
-- yes
balanceNUMERIC(19,4)
-- or
balance_centsBIGINT
The Trap Integers Do Not Fix
Division. Integers do not save you here, and this is where most implementations quietly lose money.
Split ten dollars three ways:
Math.floor(1000/3)// 333
// 333 + 333 + 333 = 999
One cent has vanished. Sum the parts and they no longer equal the whole, which is exactly the invariant your reconciliation depends on.
Allocation has to be explicit. Distribute the remainder rather than discarding it:
functionallocate(totalCents,parts){
constbase=Math.floor(totalCents/parts);
letremainder=totalCents-base*parts;
returnArray.from({length:parts},()=>
base+(remainder-->0?1:0)
);
}
allocate(1000,3);// [334, 333, 333] → sums to 1000
Which party gets the extra cent is a business decision, not a technical one, and somebody should write it down. Percentage splits, tax, and interest all need the same treatment.
Fix Two: Exact Decimal Types
When you need decimal fractions rather than integer units, use a type built for it.
Python's decimal module gives arbitrary-precision decimal arithmetic. Java has BigDecimal. C# has decimal, which is still floating point but base-10, so decimal fractions are exact. In JavaScript, reach for big.js or decimal.js.
All of these share one trap that catches people constantly:
fromdecimalimportDecimal
Decimal(0.1)
# Decimal('0.1000000000000000055511151231257827021181583404541015625')
Decimal('0.1')
# Decimal('0.1')
Construct from a string, never from a float literal. Passing a float means the damage is already done before the decimal type ever sees the value. BigDecimal in Java behaves identically, and it is the single most common way people use these libraries and still get wrong answers.
What To Change On Monday
Five things, roughly in order of how much trouble they save:
Grep for float columns holding money.REAL, FLOAT, DOUBLE PRECISION in any table with a currency amount. That is your highest-severity finding.
Move to integer minor units or an exact decimal type. Minor units for most systems, decimal for anything with fractional-unit pricing or high-precision rates.
Make allocation explicit wherever you divide. Any split, share, percentage or tax calculation needs a defined remainder rule and a test asserting the parts sum to the whole.
Push formatting to the edges. Values stay in their exact representation through the whole pipeline and become strings only at render.
Assert exact equality in tests. If a test needs an epsilon tolerance to pass on a money calculation, the calculation is wrong. Tolerance is for physics, not for balances.
None of this is difficult, and all of it is cheaper before launch than after. The floating-point behaviour is not a bug in your language, it is a documented property of the representation, which means the bug is entirely in the decision to use it for money.
Worth an hour of your week to go and look. If you want the deeper version, the database tag here has a steady stream of posts on numeric precision, and the discussion in the comments is usually where the interesting edge cases surface.
Top comments (0)