Skip to content
gohacks.dev
Basics

Why 0.1 Plus 0.2 Is Not 0.3 in Go Floating Point Maths

Floating point cannot store 0.1 exactly, so 0.1 plus 0.2 misses 0.3 by a hair. See the bits in Go, how to compare floats, and when to use integers instead.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

The first time a developer sees 0.1 + 0.2 print 0.30000000000000004, the instinct is to blame the language. It is not a Go quirk. Every language that uses IEEE 754 binary floating point, which is nearly all of them, gives the same digits, because the hardware does. What is specific to Go is how it lets you see the problem and how its constants avoid it. This article shows the actual bits, the one place where Go gets the "right" answer, and what to do about comparisons and money. It sits beside integer overflow in the Go basics path, and the two are the main ways a number does something the textbook said it would not.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Seeing the error

The program below adds 0.1 and 0.2 three different ways and prints the result with enough digits to show the difference:

main.goGo
package main

import "fmt"

func main() {
	a, b := 0.1, 0.2
	sum := a + b

	fmt.Println(sum)
	fmt.Printf("%.20f\n", sum)
	fmt.Println(sum == 0.3)

	const ca, cb = 0.1, 0.2
	fmt.Println(ca+cb == 0.3)
}
Text
0.30000000000000004
0.30000000000000004441
false
true

The sum prints as 0.30000000000000004, and the comparison with 0.3 is false. The last line prints true, and that one is a Go-specific detail, covered next. The %.20f form shows the stored value is 0.30000000000000004441, not 0.3.

The cause is not in the addition. It is in the numbers going in. 0.1 in binary is 0.0001100110011... repeating forever, the same way one third in decimal is 0.333.... A float64 has 53 bits of mantissa, so it cuts the series off and keeps the nearest value it can. The same happens to 0.2 and 0.3, and the three rounding errors do not cancel.

Constants are exact, variables are not

The true on the last line is not an accident. Go evaluates constant expressions with arbitrary precision at compile time, and only converts to float64 when the value lands in a variable or a typed context. The constant 0.1 + 0.2 is exactly 0.3 in the compiler's arithmetic, which then rounds to the same float64 as the literal 0.3. Once the values are in variables, the machine's arithmetic takes over and the answer differs. The reasoning is in the Go blog article on constants, and untyped constants explains the rule in detail. It matters when you are writing tests: a test that compares two constants will pass and tell you nothing about what the program does with real data.

What a float64 actually holds

An IEEE 754 double has one sign bit, 11 exponent bits and 52 stored fraction bits (with an implied leading one). You can pull it apart with math.Float64bits:

main.goGo
package main

import (
	"fmt"
	"math"
)

func describe(f float64) {
	bits := math.Float64bits(f)
	sign := bits >> 63
	exp := (bits >> 52) & 0x7FF
	frac := bits & (1<<52 - 1)
	fmt.Printf("%-22v sign=%d exp=%4d frac=%013x\n", f, sign, exp, frac)
}

func main() {
	a, b := 0.1, 0.2
	describe(a)
	describe(b)
	describe(a + b)
	describe(0.3)
	describe(0.5)
	describe(1.0)
}
Text
0.1                    sign=0 exp=1019 frac=999999999999a
0.2                    sign=0 exp=1020 frac=999999999999a
0.30000000000000004    sign=0 exp=1021 frac=3333333333334
0.3                    sign=0 exp=1021 frac=3333333333333
0.5                    sign=0 exp=1022 frac=0000000000000
1                      sign=0 exp=1023 frac=0000000000000

Compare the last digits of the fractions. 0.5 and 1.0 have a fraction of all zeros, because they are powers of two and the format stores them exactly. 0.1 and 0.2 end in a repeating pattern that was rounded off. The sum of the first two ends in a different hex digit from 0.3 (4 against 3 in the last place), which is why == says no. The two numbers are adjacent doubles, one step apart. There are about 17 digits of usable precision, which is the answer to "how many digits can I trust": roughly 15 are always safe, and 17 is the most you can ever get back out.

Comparing floats properly

Since results are approximations, compare them as such. The usual tool is an absolute tolerance for values near zero and a relative tolerance for everything else:

main.goGo
package main

import (
	"fmt"
	"math"
)

func nearlyEqual(a, b, absTol, relTol float64) bool {
	diff := math.Abs(a - b)
	if diff <= absTol {
		return true
	}
	return diff <= relTol*math.Max(math.Abs(a), math.Abs(b))
}

func main() {
	a, b := 0.1, 0.2
	fmt.Println(a+b == 0.3)
	fmt.Println(nearlyEqual(a+b, 0.3, 1e-12, 1e-9))

	big1, big2 := 1e15+0.3, 1e15+0.1
	fmt.Println(nearlyEqual(big1, big2, 1e-12, 1e-9))
	fmt.Println(nearlyEqual(1e-20, 0, 1e-12, 1e-9))
}
Text
false
true
true
true

The tolerances are choices, not constants of nature. A relative tolerance of 1e-9 suits most arithmetic in float64, which carries about 1e-16 of relative precision per operation, so a thousand operations still fit comfortably. In a test, the standard library does not ship a float comparison helper. Most projects write the function above, or use a testing library that has one.

Do not use math.Float64bits(a) == math.Float64bits(b) as a shortcut for exactness. It treats 0.0 and -0.0 as different, and it treats two different NaN payloads as different. That is occasionally what you want and mostly not.

NaN and infinity

Two special values come from operations that have no finite answer, and both appear in real data:

main.goGo
package main

import (
	"fmt"
	"math"
)

func main() {
	zero := 0.0
	nan := math.NaN()
	inf := 1 / zero

	fmt.Println(inf, -inf, zero/zero)
	fmt.Println(nan == nan, nan != nan, math.IsNaN(nan))

	m := map[float64]string{}
	m[nan] = "a"
	m[nan] = "b"
	_, found := m[nan]
	fmt.Println(len(m), found)
}
Text
+Inf -Inf NaN
false true true
2 false

Float division by zero does not panic. It yields +Inf, -Inf or NaN. Integer division by zero does panic. NaN is the one value that is not equal to itself, so nan == nan is false and math.IsNaN is the only reliable test. The map case is the odd one: each insert with a NaN key creates a new entry, because no key ever equals it, and the lookup never finds any of them. If you read floats from JSON or a CSV file, reject or normalize NaN before you use the value as a key. The encoding/json package refuses to encode NaN and Inf for a related reason: JSON has no way to write them.

Money does not belong in a float

The standard advice has a number attached to it. Add 0.1 to a total ten times, then compare with 1.0:

main.goGo
package main

import "fmt"

func main() {
	var total float64
	for range 10 {
		total += 0.1
	}
	fmt.Println(total, total == 1.0)

	cents := int64(0)
	for range 10 {
		cents += 10
	}
	fmt.Printf("%d.%02d %v\n", cents/100, cents%100, cents == 100)
}
Text
0.9999999999999999 false
1.00 true

Ten additions of a tenth do not reach one. The error here is about one part in 10 to the 16, which is invisible on a single transaction and shows up when a ledger has a few million rows that must sum to a known total. The fix is to stop using binary fractions for decimal quantities. Store the amount as an integer count of the smallest unit, cents for most currencies, and format at the edge. For currencies with no minor unit, such as the Japanese yen, the integer is the whole amount; for some currencies it is thousandths. Store the currency alongside the integer.

When you need fractional quantities, such as tax rates applied to prices, either carry extra decimal places in an integer or reach for a decimal library. The most used is github.com/shopspring/decimal, which stores arbitrary precision decimals. It is slower than a float and allocates, and for invoices that is the right trade. Rounding rules are a business decision too: banker's rounding, round half up and truncation give different answers, and tax authorities specify which.

Rounding and conversion to integers

Converting a float to an integer type truncates toward zero. int(2.9) is 2, int(-2.9) is -2. If you want the nearest whole number, use math.Round, which rounds half away from zero, or math.RoundToEven for banker's rounding. Converting a value outside the target type's range is not an error, and the result is implementation-defined, so check the range first with a comparison against math.MaxInt64. The rest of the conversion rules are in converting between types in Go. The rest of the conversion rules are in converting between types in Go.

Formatting is separate from the stored value. fmt.Printf("%.2f", x) rounds for display and leaves x alone, so printing 0.30 does not mean the variable holds 0.3. If the displayed number must match what you compare, round the value itself.

float32 is not the cheap safe option

float32 has 24 bits of mantissa, about 7 decimal digits. It halves the memory, which matters in a large array of coordinates or an audio buffer, and it makes every problem above worse by about nine orders of magnitude. Go's math functions are written for float64, so using float32 means constant conversions around every call. Unless you have measured a memory or bandwidth problem, use float64.

When exact decimal behavior is worth the cost

If you are building anything that records or moves money, billing, accounting or tax, use integers or a decimal type from the first line of code. Changing a ledger from float to integer after launch means migrating every stored value, and nobody enjoys it. Scientific code, graphics, machine learning and statistics are the opposite: binary floats are the right tool, and the discipline is tolerance-based comparison and awareness of error growth. For hand calculations or a quick check of how a number is stored, the IEEE 754 float converter shows the sign, exponent and fraction for any value. The Go specification on numeric types names the formats Go uses, and the math package documentation lists every constant and function mentioned here.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.