Why 0.1 + 0.2 != 0.3: What Actually Happens in IEEE 754 Memory
Every developer encounters this code snippet early on:
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004
The usual explanation is a quick hand-wave: "Computers use binary, so floating-point numbers have rounding errors."
While true, that explanation skips the actual mechanics. It does not explain why 0.5 + 0.25 === 0.75 works with zero error, why 0.1 cannot exist in finite binary, what the CPU physically stores in 64 bits of memory, or why the sum overshoots by exactly 5.551115123125783e-17.
Here is the step-by-step breakdown of what happens in physical memory and CPU floating-point registers when you add 0.1 and 0.2.
1. The Core Math: Why Binary Cannot Store 0.1
In base-10 arithmetic, a simplified fraction $p/q$ produces a terminating decimal if and only if the prime factors of the denominator $q$ consist exclusively of 2 and 5 (the prime factors of base 10).
- $1/2 = 0.5$ (terminates)
- $1/4 = 0.25$ (terminates)
- $1/5 = 0.2$ (terminates)
- $1/10 = 0.1$ (terminates)
- $1/3 = 0.333333...$ (repeats infinitely, because 3 is not a prime factor of 10)
In base-2 (binary), the base has only one prime factor: 2.
A fraction $p/q$ terminates in binary if and only if the denominator $q$ is a power of 2 ($2^k$).
- $1/2 = 0.1_2$ (terminates)
- $1/4 = 0.01_2$ (terminates)
- $1/8 = 0.001_2$ (terminates)
- $1/10 = 1 / (2 \times 5)$ (repeats forever)
Because $5$ is coprime to $2$, the decimal value $0.1$ produces an infinite repeating binary sequence:
$$0.1_{10} = 0.000110011001100110011001100110011..._2$$
The sequence 0011 repeats indefinitely. In base-2, representing 0.1 is just as impossible as writing $1/3$ in base-10 with a finite number of decimal digits.
2. The IEEE 754 Binary64 Memory Layout
Modern programming languages (JavaScript Number, Python float, C/C++/Rust double, Java double, Go float64) implement the IEEE 754 binary64 standard (double precision).
A 64-bit float is packed into three distinct fields:
1 bit 11 bits 52 bits
+------+-----------------------+--------------------------------------------------+
| Sign | Biased Exponent (11b) | Significand / Mantissa (52b) |
+------+-----------------------+--------------------------------------------------+
[63] [62:52] [51:0]
-
Sign bit ($S$, 1 bit):
0for positive,1for negative. -
Biased Exponent ($E$, 11 bits): Stores the power of 2 with an offset (bias) of
1023. An actual exponent of $-4$ is stored as $-4 + 1023 = 1019$. - Fraction / Mantissa ($M$, 52 bits): Represents the fractional part. Normalized numbers assume an implicit leading 1 before the binary point, giving 53 effective bits of precision.
The stored numerical value equals:
$$\text{Value} = (-1)^S \times 2^{E - 1023} \times \left(1 + \sum_{i=1}^{52} M_{52-i} \cdot 2^{-i}\right)$$
53 bits of binary significand translate to roughly $53 \times \log_{10}(2) \approx 15.95$ decimal digits of precision. Anything beyond that must be rounded.
3. Bit-by-Bit Breakdown: 0.1, 0.2, and 0.3 in Memory
Let us inspect the exact bit patterns generated for 0.1, 0.2, and 0.3.
The Literal 0.1
In scientific binary notation, $0.1_{10}$ is:
$$1.1001100110011001100110011001100110011001100110011001[1001...]_2 \times 2^{-4}$$
-
Exponent: $-4 + 1023 = 1019 = 01111111011_2$ (
0x3FB) -
Mantissa: The first 52 bits after the binary point are:
1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 -
Rounding: The 53rd fractional bit is
1, followed by0011.... Because the discarded bits exceed half of the least significant bit, IEEE 754 Round to Nearest, Ties to Even rounds the last bit UP from1to10.
Final 64-bit memory representation:
Hex: 0x3FB999999999999A
Bin: 0 | 01111111011 | 1001100110011001100110011001100110011001100110011010
The exact value stored in physical memory is:
0.1000000000000000055511151231257827021181583404541015625
It is slightly larger than $0.1$.
The Literal 0.2
$0.2_{10}$ is mathematically $2 \times 0.1_{10}$. The mantissa bit pattern is identical, but the exponent increments by 1:
-
Exponent: $-3 + 1023 = 1020 = 01111111100_2$ (
0x3FC) -
Mantissa:
1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010
Final 64-bit memory representation:
Hex: 0x3FC999999999999A
Bin: 0 | 01111111100 | 1001100110011001100110011001100110011001100110011010
The exact value stored in memory is:
0.200000000000000011102230246251565404236316680908203125
The Literal 0.3
When a compiler evaluates the literal 0.3, it parses $3/10$:
$$1.0011001100110011001100110011001100110011001100110011[0011...]_2 \times 2^{-2}$$
-
Exponent: $-2 + 1023 = 1021 = 01111111101_2$ (
0x3FD) -
Mantissa: The first 52 bits are:
0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 -
Rounding: The 53rd fractional bit is
0. The discarded bits are less than half an LSB, so rounding truncates DOWN.
Final 64-bit memory representation:
Hex: 0x3FD3333333333333
Bin: 0 | 01111111101 | 0011001100110011001100110011001100110011001100110011
The exact value stored in memory is:
0.299999999999999988897769753748434595763683319091796875
Notice that literal 0.3 is slightly smaller than mathematical $0.3$.
4. Inside the FPU: What Happens During 0.1 + 0.2
When the CPU executes floating-point addition (via the addsd instruction on x86-64 or fadd on ARM64), the hardware Floating-Point Unit (FPU) performs four distinct operations:
+---------------------+ +----------------------+ +-----------------------+ +------------------------+
| 1. Align Exponents | --> | 2. Add Significands | --> | 3. Normalize Result | --> | 4. Round to 52-bit LSB |
| (Shift 0.1 right 1) | | (Include Guard bits) | | (Shift right, exp +1) | | (Round UP to even) |
+---------------------+ +----------------------+ +-----------------------+ +------------------------+
Step 1: Align Exponents
You cannot add floating-point significands with different exponents. The FPU takes the operand with the smaller exponent (0.1, $2^{-4}$) and shifts its significand right by 1 bit to match 0.2 ($2^{-3}$):
0.1 significand (aligned): 0.11001100110011001100110011001100110011001100110011010 x 2^-3
0.2 significand: 1.10011001100110011001100110011001100110011001100110100 x 2^-3
Step 2: Add the Significands
The hardware adder computes the raw binary sum:
0.11001100110011001100110011001100110011001100110011010
+ 1.10011001100110011001100110011001100110011001100110100
------------------------------------------------------------
= 10.01001100110011001100110011001100110011001100110011110 x 2^-3
Step 3: Normalize
The result has two bits before the binary point (10.01...). The FPU shifts the significand right by 1 bit and increments the exponent from $-3$ to $-2$:
1.00100110011001100110011001100110011001100110011001111 [10] x 2^-2
Step 4: Rounding
The bits beyond bit 52 are 10 (Guard bit = 1, Round bit = 0, Sticky bit = 0). Because the remainder is at the rounding threshold and the preceding bit is 1, Round-to-Nearest-Ties-to-Even rounds UP.
Adding 1 to the least significant bit causes a carry across the trailing ones:
...00110011001111 + 1 -> ...00110011010000
The resulting 52-bit fraction ends in ...0100:
Hex: 0x3FD3333333333334
Bin: 0 | 01111111101 | 0011001100110011001100110011001100110011001100110100
The exact value produced by the addition is:
0.3000000000000000444089209850062616169452667236328125
5. The Direct Comparison: 1 ULP of Difference
Let us compare the two values directly:
| Expression | Hex Representation | Exact Decimal Value Stored |
|---|---|---|
0.3 (literal) |
0x3FD3333333333333 |
0.2999999999999999888977697537... |
0.1 + 0.2 (computed) |
0x3FD3333333333334 |
0.3000000000000000444089209850... |
The difference in their 64-bit integer bit representations is exactly 1.
This 1-bit difference at the least significant position is called 1 ULP (Unit in the Last Place).
$$\text{Difference} = 2^{-54} \approx 5.551115123125783 \times 10^{-17}$$
When the CPU runs an equality check (ucomisd on x86 or fcmp on ARM), it compares the bit patterns in the register. Because 0x3FD3333333333334 != 0x3FD3333333333333, the CPU sets the zero flag to false.
6. Where Floating-Point Traps Hurt Production Systems
Understanding IEEE 754 mechanics is essential because floating-point inaccuracies cause real bugs in production environments.
Trap 1: Catastrophic Cancellation
When you subtract two nearly equal floating-point numbers, the identical leading bits cancel out. The FPU shifts the remaining noisy low-order bits up into the significant digits, destroying precision.
a = 1.0000000000000002
b = 1.0000000000000000
diff = a - b
print(diff) # 2.220446049250313e-16 (accurate)
c = 1.0000000000000000 + 1e-17
print(c - 1.0) # 0.0 (total data loss: 1e-17 fell off the 53-bit mantissa)
Trap 2: Math Is Not Associative
In pure mathematics, $(a + b) + c = a + (b + c)$. In floating-point arithmetic, this invariant is broken:
const a = 1e20;
const b = -1e20;
const c = 3.14159;
console.log((a + b) + c); // 3.14159
console.log(a + (b + c)); // 0 (b + c rounded to -1e20, then cancelled with a)
This non-associativity is why parallel reductions (such as GPU matrix multiplications or multithreaded array sums) can yield slightly different results between runs if threads accumulate numbers in different orders.
Trap 3: Accumulating Drift in Loop Counters
Using floating-point increments in loop conditions leads to off-by-one errors:
// Dangerous: 0.1 cannot be represented exactly, drift accumulates
for (double x = 0.0; x <= 1.0; x += 0.1) {
printf("%f\n", x);
}
// Depending on compiler optimizations and register widths (e.g. 80-bit x87 vs 64-bit SSE),
// this loop may execute 10 or 11 times.
Fix: Use integer loop counters and scale the value:
for (int i = 0; i <= 10; ++i) {
double x = i / 10.0;
printf("%f\n", x);
}
7. How to Handle Precision Correctly in Production
1. For Money: Never Use Floats
Financial calculations must never use binary floating-point types.
-
Integer Cents/Micros: Store all values in the lowest indivisible unit (e.g.
$19.99becomes1999cents or19990000micros). Stripe, PayPal, and ledger systems store integer balances. -
Exact Decimal Types: Use arbitrary-precision decimal representations:
-
PostgreSQL:
NUMERIC/DECIMAL -
Python:
decimal.Decimal -
Java / Kotlin:
BigDecimal -
C# / .NET:
decimal -
TypeScript / JS:
decimal.jsorbignumber.js
-
PostgreSQL:
from decimal import Decimal
# Decimal uses base-10 arithmetic under the hood
a = Decimal("0.1")
b = Decimal("0.2")
print(a + b == Decimal("0.3")) # True
2. For Scientific/Engineering Code: Compare with Epsilon
Never compare floating-point numbers with === or ==. Compare their absolute or relative difference against machine epsilon ($\epsilon = 2^{-52} \approx 2.22 \times 10^{-16}$):
function areFloatsEqual(
a: number,
b: number,
relTol: number = 1e-9,
absTol: number = 1e-12
): boolean {
return Math.abs(a - b) <= Math.max(relTol * Math.max(Math.abs(a), Math.abs(b)), absTol);
}
console.log(areFloatsEqual(0.1 + 0.2, 0.3)); // true
In Python, use the standard library's math.isclose():
import math
print(math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9)) # True
Summary Checklist
-
Base-10 vs Base-2: Numbers terminate in binary only if their denominator is a power of 2.
0.1($1/10$) is an infinite repeating binary fraction (0.000110011...). - Double Precision (binary64): Uses 1 sign bit, 11 exponent bits (bias 1023), and 52 fraction bits (53 effective bits with the implicit 1).
-
Rounding Bias:
0.1rounds UP to0x3FB999999999999A,0.2rounds UP to0x3FC999999999999A, and literal0.3rounds DOWN to0x3FD3333333333333. -
The Addition: Adding
0.1 + 0.2requires exponent alignment and normalization, which results in0x3FD3333333333334. -
The Discrepancy: The difference between computed
0.1 + 0.2and literal0.3is exactly 1 ULP ($5.55 \times 10^{-17}$). -
Production Rule: Use integers or decimal types for currency; use epsilon tolerance (
math.isclose/Number.EPSILON) for scientific equality checks.
Top comments (0)