Command Palette

Search for a command to run...

GitHub
Back to blog

Why 0.1 + 0.2 Is Not 0.3: A Guide to Numeric Correctness

Samith ReddyMay 20, 20265 min read
systemscs fundamentalshow computers work
Share:

In JavaScript, this comparison is false:

console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

The interesting question is where the difference enters the calculation. There are three separate steps: represent the inputs, round the result, and format the output. Confusing those steps leads to fixes that change what a number looks like without changing its behavior.

For engineering work, the goal is to choose a representation and an error policy that match the data.

Finite decimal fractions can repeat in binary

In base 10, one third needs an infinite sequence of digits. In base 2, one tenth has the same problem:

1/3 in decimal = 0.3333333333...
1/10 in binary = 0.00011001100110011...

For a fraction in lowest terms, a finite binary expansion requires a denominator that is a power of two. That makes 1/2, 1/4, and 3/8 straightforward. The denominator of 1/10 contains a factor of five, so its binary expansion repeats.

A fixed-width representation must round that expansion. Even fractions with finite expansions must fit the format's precision and exponent range.

What binary64 actually stores

JavaScript's Number uses the IEEE 754 binary64 format. Its fields contain one sign bit, 11 exponent bits, and 52 fraction bits. Normal values have an implicit leading bit, which gives 53 bits of significand precision. See the ECMAScript definition of the Number type.

For normal values, the representation is:

value = (-1)^sign × (1 + fraction / 2^52) × 2^(exponent - 1023)

Zero, subnormal values, infinities, and NaN use special encodings. The formula above describes the ordinary normalized case.

The exact decimal values behind the familiar literals are:

0.1 → 0.1000000000000000055511151231257827021181583404541015625
0.2 → 0.200000000000000011102230246251565404236316680908203125
0.3 → 0.299999999999999988897769753748434595763683319091796875

These values already differ from the decimal fractions before addition occurs.

Addition introduces another rounding step

The exact sum of the stored inputs is:

0.3000000000000000166533453693773481063544750213623046875

That sum lies halfway between two adjacent binary64 values. Under round-to-nearest, ties-to-even, it rounds to:

0.3000000000000000444089209850062616169452667236328125

This is the next representable value above the stored value of 0.3. The equality comparison fails because the two operands have different representations.

Printing adds a separate decision. A language can display a short decimal string that converts back to the same stored value. Python's floating-point tutorial explains this distinction between representation and display.

Formatting the sum as 0.30 can be appropriate for a screen. It does not make subsequent arithmetic exact.

Go exposes an important distinction

The same source expression does not behave identically in every language. In Go, untyped numeric constants support exact constant arithmetic before conversion to a runtime type. The Go specification defines these rules.

This complete program shows both cases:

package main
 
import "fmt"
 
func main() {
    fmt.Println(0.1 + 0.2)        // 0.3
    fmt.Println(0.1 + 0.2 == 0.3) // true
 
    a, b := 0.1, 0.2 // Each variable has type float64.
    fmt.Println(a + b)        // 0.30000000000000004
    fmt.Println(a + b == 0.3) // false
 
    if 0.1+0.2 != 0.3 || a+b == 0.3 {
        panic("unexpected constant or float64 behavior")
    }
}

In the first expression, Go adds the constants before converting the result for printing. In the second, each assignment first rounds its input to float64.

This is why a useful numeric bug report includes the types and assignments, not only the final expression.

Choose the representation from the requirement

Integer units for discrete quantities

When the application defines an amount in whole paisa, 1050 represents ₹10.50 without a fractional approximation. Addition remains exact within the integer's range.

That choice still needs a contract. Store the currency, validate the range, and define how tax, discounts, and division round to a whole unit. Do not assume every currency has two decimal places.

Parse decimal input according to that contract. Multiplying an already rounded binary float by 100 can preserve the error you wanted to avoid.

Decimal arithmetic for decimal rules

Decimal types can represent finite decimal inputs exactly within their supported precision. Construct them from strings when the source value is decimal text:

from decimal import Decimal, ROUND_HALF_EVEN
 
assert Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
assert Decimal(0.1) != Decimal("0.1")
 
amount = Decimal("10.005")
rounded = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
assert rounded == Decimal("10.00")

Decimal(0.1) receives a binary float that has already been rounded. A decimal constructor cannot infer the original input text.

Decimal arithmetic also has limits. Division such as one third still needs rounding, and the active precision can affect calculations. Python's decimal documentation describes precision, rounding modes, and signals. The rounding mode above is an example policy, not a universal rule for payments.

PostgreSQL's numeric type provides decimal storage and arithmetic. A declared scale can round incoming values, so database precision must agree with the application's contract.

Binary floats for approximate measurements

For measurements, geometry, and many numerical algorithms, a binary float is appropriate. The comparison then needs an error budget.

A relative tolerance scales with the operands. An absolute tolerance sets a floor near zero. Python's math.isclose combines them:

import math
 
assert math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-12, abs_tol=0.0)
assert not math.isclose(0.0, 1e-12, rel_tol=1e-9, abs_tol=0.0)
assert math.isclose(0.0, 1e-12, rel_tol=1e-9, abs_tol=1e-11)

For finite operands, the comparison accepts:

abs(a - b) <= max(rel_tol × max(abs(a), abs(b)), abs_tol)

The values above demonstrate the mechanism. Choose actual tolerances from units, measurement uncertainty, and accumulated error. A single arbitrary epsilon cannot serve every scale.

Exact equality remains useful when exact equality is the requirement. Two deliberately identical stored values can be compared directly. A tolerance belongs where approximate agreement has a defined meaning.

Test the numeric contract

The examples above include assertions so their claims can be checked. In application code, add boundary cases that express the domain: maximum amounts, rounding ties, values near zero, and repeated calculations.

Also test the path through storage and serialization. Correct arithmetic in one process does not help if an API converts a decimal amount to a binary float unexpectedly.

Before choosing a type, answer three questions: what values must be represented, what operations will occur, and what error is acceptable? Those answers turn a surprising expression into an explicit engineering decision.

Samith Reddy
Written by Samith Reddy

Backend and AI engineer building reliable systems with careful product details.

Comments

Join the discussion on GitHub Discussions. Sign in with your GitHub account to leave a comment.