Fundamentals
Why is 0.1 + 0.2 not 0.3?
By Muhammad UmarJuly 24, 202512 min readIssue #6
One tenth is a repeating fraction in binary, in exactly the way one third is in decimal. Everything else follows from that.
Write down one third as a decimal. You get 0.333, and then you keep going, and you never stop. Nobody finds this upsetting. Base ten simply has no exact way to write one third, so we round, we write 0.333, and we get on with our lives.
Computers count in base two. And in base two, one tenth is the awkward one.
That is the whole answer. Everything below is the detail, but if you remember one sentence, remember that one: your computer is not broken and floating point is not buggy. You handed it a fraction that its number system cannot write down, and it did the same thing you do with one third. It rounded.
So the famous result is not a defect. 0.1 + 0.2 gives 0.30000000000000004 because you never gave it 0.1 or 0.2 in the first place. You gave it the closest numbers it could hold, and those were slightly off before any addition happened.
The problem, concretely
Here is the version that costs money rather than the version that surprises you in a console.
// Ten items at 10 cents each.
let total = 0
for (let i = 0; i < 10; i++) {
total += 0.1
}
console.log(total) // 0.9999999999999999
console.log(total === 1) // false
console.log(total.toFixed(2)) // "1.00"The last two lines are the dangerous pair. Your comparison says the basket does not total a pound. Your invoice, formatted to two decimal places, says it does. Both are reading the same variable.
Nothing here throws. Nothing logs a warning. The discrepancy is 0.0000000000000001, which survives every test you would think to write and every glance at the output, right up until it lands in a reconciliation report that has to balance to the penny.
Why base matters
A fraction can be written exactly in a given base only when its denominator divides evenly into powers of that base. That sounds abstract, so here is what it means in practice.
Base ten is built from 2 and 5, the factors of ten. So halves, quarters, fifths, tenths, and twentieths all terminate. One third does not, because 3 is not among those factors, and no amount of decimal places will ever finish it.
Base two is built from 2 alone. So halves, quarters, and eighths terminate exactly. Anything with a 5 in the denominator does not. One fifth repeats forever. One tenth repeats forever. Three tenths repeats forever.
in base 10 in base 2
0.5 exact 0.1 exact
0.25 exact 0.01 exact
0.125 exact 0.001 exact
0.1 exact 0.000110011001100110011… repeats
0.2 exact 0.001100110011001100110… repeats
0.3 exact 0.010011001100110011001… repeatsLook at that table again, because it explains something people find arbitrary. Prices are almost always tenths and hundredths. Tenths and hundredths are precisely the fractions binary cannot hold. Money is the single worst possible use of binary floating point, and it is the thing people reach for it to do.
Nothing is wrong with the arithmetic. You are asking for a number that does not exist in the system you are asking.
What is actually stored
A double, which is what almost every language means by a floating point number and the only numeric type JavaScript has, is 64 bits arranged in three parts.1
The exponent chooses a scale, roughly which power of two you are near. The 52 fraction bits then pick one of about four and a half quadrillion evenly spaced steps within that scale. Every number a double can hold is one of those steps. There is nothing in between.
So when you write 0.1, the machine finds the nearest available step and uses that. Here is what it actually holds, printed to its full exact value rather than the friendly version:
you wrote it stored (exactly)
0.1 0.1000000000000000055511151231257827021181583404541015625
0.2 0.200000000000000011102230246251565404236316680908203125
------------------------------------------------------
sum 0.3000000000000000444089209850062616169452667236328125
0.3 0.299999999999999988897769753748434595763683319091796875The sum of the two stored values is a different number from the stored value of 0.3. Not because addition went wrong. The addition is exactly right. The two inputs were each already rounded, in the same direction, and the errors added up rather than cancelling out.
Your language then prints 0.30000000000000004 because it prints the shortest decimal that uniquely identifies that particular step, and for this one that takes seventeen digits.3 The extra digits are not noise. They are the machine being precise about a value that is genuinely not 0.3.
The gaps get wider as the numbers get bigger
There is a second consequence of that layout, and it catches people who never touch money.
Because the exponent scales the steps, the spacing between neighbouring doubles grows with magnitude. Near 1, consecutive doubles are about 0.00000000000000022 apart. Near 9,007,199,254,740,992, which is 2 to the power 53, they are 2 apart.
Above that threshold a double can no longer represent every whole number. Add 1 and you get the same value back, silently.
const big = 9007199254740992 // 2 ** 53
console.log(big + 1) // 9007199254740992
console.log(big + 1 === big) // trueThis is why a 64 bit identifier from an API arrives in a browser with its last digits wrong. JavaScript parsed it into a double, the value needed more than 53 bits of precision, and the nearest available step was a different number. No error was raised, because from the machine’s point of view nothing unusual happened.3
Four ways to deal with it, and when each is wrong
Store integers in the smallest unit
Keep money in cents, not pounds. 1099, not 10.99. Integers up to 2 to the power 53 are exact in a double, and in most languages you have a genuine integer type anyway.
// pence, as whole numbers
let total = 0
for (let i = 0; i < 10; i++) {
total += 10
}
console.log(total) // 100, exactly
console.log(total === 100) // trueThis is the right default for money, and it is what most payment systems do internally.
Wrong when your smallest unit is not actually the smallest unit. Currencies with three decimal places exist, unit prices are quoted in fractions of a cent, and interest and tax calculations produce fractions of your chosen unit. The moment you divide, you are back to rounding, and now you are rounding by hand with no help from the type system.
Use a decimal type
Databases and most languages offer arbitrary precision decimals: numeric in PostgreSQL,5 Decimal in Python,4 BigDecimal in Java. These store digits in base ten, so 0.1 is exactly 0.1 and the problem simply does not arise.
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2')) # 0.3
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3')) # True
# But note where the value comes from:
print(Decimal(0.1)) # 0.1000000000000000055511151231257827021181583404541015625That last line is the trap people fall into. Constructing a decimal from a float inherits the error you were trying to escape. Always build them from strings.
Wrong when you are doing volume arithmetic. Decimal types are implemented in software rather than on the arithmetic unit, so the cost per operation is far higher than a hardware double. For a million invoice lines that is irrelevant. For a physics loop or a machine learning workload it is the wrong tool by a wide margin.
Use exact rationals
Store a numerator and a denominator and never divide at all. One third stays exactly one third rather than becoming 0.333.
Wrong when the denominators grow. Repeated arithmetic on rationals makes the numbers themselves grow without bound, and a computation that runs indefinitely will eventually spend all its time on arithmetic with enormous integers. This is a good fit for symbolic mathematics and a poor one for anything that loops.
Compare with a tolerance
If you must stay in floating point, stop testing for equality. Test whether two values are close enough that the difference cannot matter.
// Wrong: exact equality on values that were rounded on the way in.
if (a === b) { /* ... */ }
// Better: relative tolerance, scaled to the size of the numbers.
function closeEnough(a, b, tolerance = 1e-9) {
return Math.abs(a - b) <= tolerance * Math.max(Math.abs(a), Math.abs(b), 1)
}Note that the tolerance is relative rather than a fixed amount. A fixed tolerance of 0.0000001 is generous near 1 and meaningless near a million, where the gap between neighbouring doubles is already larger than that.
Wrong when the answer has to be exact rather than close. No tolerance makes a ledger balance. If two figures are meant to be identical and a regulator will ask, "close enough" is not a property you are allowed to have.
| Approach | Use for | Cost | Fails when |
|---|---|---|---|
| Integer minor units | Money, counts, anything discrete | None, this is native arithmetic | You need to divide, or the unit gets smaller |
| Decimal type | Finance where the maths is involved | Software arithmetic, many times the cost per operation | Volume computation; built from a float by mistake |
| Exact rationals | Symbolic work, exact fractions | Denominators grow without bound | Long-running loops |
| Tolerant comparison | Measurement, geometry, science | None, but you must choose the tolerance | The answer must be exact |
When floating point is the right answer
Everything above is a list of escapes, which makes it easy to conclude that binary floating point is a mistake to be avoided. It is not, and treating it that way will lead you into worse decisions than the one this article started with.
Doubles carry roughly 15 to 17 significant decimal digits. If your input is a temperature sensor accurate to a tenth of a degree, or a distance measured with a tape, your measurement error is already many orders of magnitude larger than anything the representation introduces. Reaching for exact decimals there buys you precision your data never had, at a real cost per operation.
The rule that actually separates the cases is not about precision at all. Ask whether your quantity is counted or measured. Counted things are exact by nature: pennies, items, votes, bytes. They should be integers, and rounding them is an error. Measured things arrived with uncertainty already attached: temperature, distance, weight, duration. Floating point is built for those, and its rounding is smaller than the noise already in your data.
Physics engines, graphics pipelines, statistical models, and neural networks all run on floating point, and many run on 32 or even 16 bit floats deliberately, accepting more rounding in exchange for throughput. That is not carelessness. It is a correct reading of what their inputs are worth.
Living with it well
Addition is not associative
This one surprises people who accept everything else. Because each step rounds, the order you add in changes the answer.
const a = 1e16
const b = -1e16
const c = 1
console.log((a + b) + c) // 1
console.log(a + (b + c)) // 0In the second case, adding 1 to a number of magnitude 10 to the power 16 changes nothing, because the gap between neighbouring doubles there is larger than 1. The 1 is absorbed and vanishes. Then the two large values cancel and you are left with nothing.
The practical consequence: summing a large list from smallest to largest loses less than summing it in arbitrary order, because small values get to accumulate into something big enough to survive being added to the running total. If you cannot sort, compensated summation tracks the discarded low-order bits in a second variable and folds them back in, which keeps the error bounded regardless of how many values you add.6
Subtracting two close numbers destroys precision
If two values agree to twelve digits and you subtract them, the leading twelve digits cancel and what remains is built from the few uncertain digits at the end. The result looks precise, with a full set of digits after it, and almost all of them are noise. This is called catastrophic cancellation, and it is the failure mode behind most numerical results that are wrong without looking wrong.2
The fix is usually to rearrange the algebra so the subtraction never happens. The quadratic formula is the classic case: computed directly, one of its two roots loses most of its precision whenever the discriminant is close to the linear coefficient, and a rearranged form recovers it exactly.
Never accumulate money in a float, even briefly
A value that passes through a double, even momentarily, has been rounded. Reading a numeric column into a float variable, summing there, and writing it back gives you a database that stores exact decimals and an application that quietly corrupts them in transit.
Where this is heading
IEEE 754 has specified decimal floating point formats since 2008, with the same idea as a Decimal type but designed for hardware.1 Some IBM processors implement them directly. Most do not, so on the machine you are reading this on, decimal arithmetic is still software.
There is also active work on alternatives to the fixed exponent and fraction split, most visibly posit arithmetic, which varies how many bits go to each depending on magnitude. The pitch is more precision near 1, where most real computation happens, and less out at the extremes where it is rarely needed. Whether that displaces a standard with four decades of hardware behind it is a different question from whether it is a better design.
Meanwhile the practical situation has not changed since 1985 and is not about to. Binary floating point is superb at what it was built for and unsuitable for money, and the entire difficulty is that one default type is used for both.
So the question worth asking of any number in your system is the one from earlier: was this counted, or was it measured? Counted things want integers or decimals. Measured things want floats. Almost every floating point bug you will meet is a counted thing that was handed to a type built for measured ones.
Sources
- IEEE 754-2019, Standard for Floating-Point Arithmetic. Defines the binary64 format used here, and the decimal formats added in the 2008 revision.
- David Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, ACM Computing Surveys 23(1), 1991. Still the standard reference on cancellation and error analysis.
- ECMAScript Language Specification, The Number Type. JavaScript numbers are IEEE 754 binary64, which is why integer precision stops at 2 to the power 53.
- Python documentation, Floating Point Arithmetic: Issues and Limitations, and the
decimalmodule documentation. - PostgreSQL documentation, Numeric Types.
numericis exact and stored in base ten;realanddouble precisionare IEEE 754 and are documented as unsuitable for money. - Compensated summation, introduced by William Kahan in 1965 and analysed in Nicholas Higham, Accuracy and Stability of Numerical Algorithms, 2nd edition, 2002.
