DEV Community

Cover image for Why 0.1 + 0.2 != 0.3: What Actually Happens in IEEE 754 Memory
Syed Anzar
Syed Anzar

Posted on

Why 0.1 + 0.2 != 0.3: What Actually Happens in IEEE 754 Memory

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
  1. Sign bit ($S$, 1 bit): 0 for positive, 1 for negative.
  2. 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$.
  3. 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 by 0011.... Because the discarded bits exceed half of the least significant bit, IEEE 754 Round to Nearest, Ties to Even rounds the last bit UP from 1 to 10.

Final 64-bit memory representation:

Hex: 0x3FB999999999999A
Bin: 0 | 01111111011 | 1001100110011001100110011001100110011001100110011010
Enter fullscreen mode Exit fullscreen mode

The exact value stored in physical memory is:

0.1000000000000000055511151231257827021181583404541015625
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The exact value stored in memory is:

0.200000000000000011102230246251565404236316680908203125
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The exact value stored in memory is:

0.299999999999999988897769753748434595763683319091796875
Enter fullscreen mode Exit fullscreen mode

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)     |
+---------------------+     +----------------------+     +-----------------------+     +------------------------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Add the Significands

The hardware adder computes the raw binary sum:

  0.11001100110011001100110011001100110011001100110011010
+ 1.10011001100110011001100110011001100110011001100110100
------------------------------------------------------------
= 10.01001100110011001100110011001100110011001100110011110 x 2^-3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The resulting 52-bit fraction ends in ...0100:

Hex: 0x3FD3333333333334
Bin: 0 | 01111111101 | 0011001100110011001100110011001100110011001100110100
Enter fullscreen mode Exit fullscreen mode

The exact value produced by the addition is:

0.3000000000000000444089209850062616169452667236328125
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.99 becomes 1999 cents or 19990000 micros). 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.js or bignumber.js
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Summary Checklist

  1. 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...).
  2. Double Precision (binary64): Uses 1 sign bit, 11 exponent bits (bias 1023), and 52 fraction bits (53 effective bits with the implicit 1).
  3. Rounding Bias: 0.1 rounds UP to 0x3FB999999999999A, 0.2 rounds UP to 0x3FC999999999999A, and literal 0.3 rounds DOWN to 0x3FD3333333333333.
  4. The Addition: Adding 0.1 + 0.2 requires exponent alignment and normalization, which results in 0x3FD3333333333334.
  5. The Discrepancy: The difference between computed 0.1 + 0.2 and literal 0.3 is exactly 1 ULP ($5.55 \times 10^{-17}$).
  6. Production Rule: Use integers or decimal types for currency; use epsilon tolerance (math.isclose / Number.EPSILON) for scientific equality checks.

Top comments (0)