Floating Point Pitfalls in Go
Go’s
float32andfloat64are IEEE 754 binary floating-point types, and they inherit every IEEE 754 surprise: most decimal fractions (including0.1and0.2) cannot be represented exactly,0.1 + 0.2is0.30000000000000004, equality with==is unsafe, and the special value NaN (“not a number”) is not equal to itself. Go layers one extra twist on top: untyped constant arithmetic happens at compile time with arbitrary precision and cannot produce infinity or NaN, while runtime arithmetic onfloat64variables follows machine IEEE 754 rules and can. The same-looking expression therefore behaves differently depending on whether its operands are constants or variables (Go spec, Constants).
Mental Model
The core mental model has two layers, and the bugs live in the gap between them.
Layer 1 — the representation. A float64 is 64 bits split into a sign, an 11-bit exponent, and a 52-bit fraction (IEEE 754 binary64). It represents a number as ±fraction × 2^exponent. Because the fraction is binary, a value is exactly representable only if it is a sum of powers of two. 0.5 (= 2⁻¹) is exact; 0.1 is not — it is a repeating binary fraction, just as 1/3 is a repeating decimal. So 0.1 stored in a float64 is actually the nearest representable value, 0.1000000000000000055511151231257827021181583404541015625. Every decimal-fraction literal that is not a dyadic rational is already wrong the moment it is stored.
Layer 2 — constants vs runtime. Go’s untyped numeric constants are not float64. The spec: “Numeric constants represent exact values of arbitrary precision and do not overflow. Consequently, there are no constants denoting the IEEE 754 negative zero, infinity, and not-a-number values” (Go spec, Constants). Constant arithmetic runs in a compiler with at least 256 bits of mantissa. Only when a constant is assigned to a float64 variable (or otherwise forced to a float type) is it rounded to 53 bits of precision and exposed to IEEE 754 behavior.
flowchart TD LIT["literal 0.1, 0.2 in source"] --> Q{"used as constant<br/>or assigned to float64 var?"} Q -->|"constant expression<br/>(.1 + .2)"| C["compile-time arithmetic<br/>≥256-bit mantissa, exact<br/>→ rounds to 0.3 on use"] Q -->|"float64 variable<br/>(a := 0.1; b := 0.2; a+b)"| R["runtime IEEE 754 binary64<br/>53-bit precision"] R --> ERR["0.1, 0.2 already rounded;<br/>sum = 0.30000000000000004"] R --> NAN["÷0, 0/0, Inf−Inf<br/>→ Inf / NaN possible"] C --> NOSPEC["Inf / NaN IMPOSSIBLE<br/>overflow → compile error"]
Diagram: the same 0.1 + 0.2 text takes two completely different paths depending on whether it is a constant expression or runtime arithmetic on variables. The insight: a bug that “only appears with variables, not constants” is this fork — not randomness.
Mechanical Walk-through
Why 0.1 + 0.2 != 0.3 for variables but not constants
The reference 0.30000000000000004.com demonstrates Go’s behavior directly:
fmt.Println(.1 + .2) // 0.3
var a, b float64 = .1, .2
fmt.Println(a + b) // 0.30000000000000004
fmt.Printf("%.54f\n", .1+.2) // 0.299999999999999988897769753748434595763683319091796875.1 + .2 as an untyped constant expression is computed by the compiler at arbitrary precision — exactly 0.3 — and only then, when Println needs a value, is it converted to its default type float64, rounding to the nearest representable value and printing as 0.3. The variables a and b, by contrast, were each individually rounded to float64 at assignment: a holds slightly-more-than-0.1, b holds slightly-more-than-0.2, and their float64 sum rounds to 0.30000000000000004. The third line shows the true stored bits of the constant 0.3 once forced into float64. Nothing is random: it is deterministic IEEE 754 rounding.
NaN and the broken reflexivity of ==
IEEE 754 defines NaN as the result of indeterminate operations: 0.0/0.0, Inf - Inf, Inf / Inf, 0 * Inf, Sqrt of a negative, etc. NaN’s defining property is that every comparison involving it (except !=) is false: NaN == NaN is false, NaN < x is false, NaN >= x is false. Only NaN != anything is true.
This breaks the reflexivity that == normally guarantees (x == x). The consequence ripples into the language: a float64 containing NaN used as a map key can never be looked up, because the runtime’s key comparison uses == and NaN == NaN is false — you can store under a NaN key and never retrieve it, and two separate NaN insertions create two distinct entries. The Go spec acknowledges this: numeric constants cannot be NaN, but runtime floats can, and the standard map/comparison machinery treats NaN per IEEE 754.
Infinity
math.Inf(1) is positive infinity, math.Inf(-1) is negative infinity. They arise at runtime from overflow (1e308 * 10) and from x/0 where x is a non-zero float. Unlike integer division by zero — which the spec mandates as a run-time panic (“if the divisor is zero at run time, a run-time panic occurs”, Arithmetic operators) — floating-point division by zero does not panic on the standard gc toolchain: 1.0/0.0 yields +Inf, -1.0/0.0 yields -Inf, and 0.0/0.0 yields NaN. Read the spec wording carefully, though: it deliberately stops short of guaranteeing this. The spec says “The result of a floating-point or complex division by zero is not specified beyond the IEEE-754 standard; whether a run-time panic occurs is implementation-specific” (Arithmetic operators). That is, the spec promises only that the result conforms to IEEE 754 and explicitly reserves the right for an implementation to panic instead. In practice the official toolchain never panics on float divide-by-zero and produces the IEEE 754 ±Inf/NaN results above, but a conforming Go implementation is permitted to differ — see the uncertainty callout below. Note also that the literal 1.0/0.0 written as a constant expression is a compile error (constants cannot be Inf), so this only happens with variables.
Operation fusion — the same expression can give two different float64 answers
There is a second Go-specific fork, less famous than constants-vs-variables but equally capable of producing “this works in one place and not another” bugs. The spec explicitly permits the compiler to fuse floating-point operations: “An implementation may combine multiple floating-point operations into a single fused operation, possibly across statements, and produce a result that differs from the value obtained by executing and rounding the instructions individually” (Arithmetic operators). The canonical case is the fused multiply-add (FMA): an expression like x*y + z may be compiled to a single hardware FMA instruction that computes x*y at full internal precision and rounds only once at the end, instead of rounding x*y to a float64 first and then adding z (two roundings). The FMA result is usually more accurate — but it is a different bit pattern, and that difference is the trap.
The consequences are concrete. The expression x*y - x*y, which you would expect to be exactly 0.0, can evaluate to a tiny nonzero value if one x*y is fused into a surrounding operation and the other is not, because the two x*y subterms were rounded differently. Whether fusion happens depends on the target architecture (only CPUs with an FMA instruction), the optimizer, and the exact surrounding code — so the same source line can yield 0.0 on one platform or build and a nonzero residue on another. The spec gives the escape hatch in the very next sentence: “An explicit floating-point type conversion rounds to the precision of the target type, preventing fusion that would discard that rounding” (Arithmetic operators). So float64(x*y) - float64(x*y) forces each product to round to float64 before the subtraction, defeating fusion and guaranteeing 0.0. The lesson: if you are relying on a specific rounding outcome of an intermediate product (rare, but it happens in numerical kernels and in tests that assert exact float values), insert an explicit float64(...) conversion around the intermediate to pin it; otherwise the compiler is free to silently change the answer.
The math package’s role
Because you cannot write NaN or Inf as constants and cannot test for NaN with ==, the math package provides the necessary primitives (math package):
math.NaN()— returns a NaNfloat64.math.IsNaN(f)— the only correct NaN test; it works by exploitingf != finternally.math.Inf(sign)— returns±Inf.math.IsInf(f, sign)— tests for infinity.math.MaxFloat64,math.SmallestNonzeroFloat64— range limits.
Code Examples
Never compare floats with ==
a := 0.1 + 0.2
fmt.Println(a == 0.3) // false — a is 0.30000000000000004
// CORRECT — compare within a tolerance (epsilon):
const eps = 1e-9
fmt.Println(math.Abs(a-0.3) < eps) // truea == 0.3 compares the runtime sum (0.30000000000000004) against the float64 rounding of the constant 0.3 — different bit patterns, so false. The fix is an epsilon comparison: treat two floats as equal if their absolute difference is below a small threshold. Choosing eps is itself a judgement call — a fixed epsilon like 1e-9 works for values near 1.0 but is wrong for very large values (where the gap between adjacent floats exceeds 1e-9) or very small ones. For robust comparison, a relative epsilon (math.Abs(a-b) <= eps * math.Max(math.Abs(a), math.Abs(b))) scales with magnitude.
Testing for NaN — == does not work
n := math.NaN()
fmt.Println(n == n) // false — NaN is never equal to anything
fmt.Println(n != n) // true — the one comparison that holds
fmt.Println(math.IsNaN(n)) // true — the correct testn == n being false is the surprise. If you wrote if x == x expecting it to always be true, NaN breaks it. math.IsNaN is the only correct check; it is defined in terms of f != f, turning the IEEE 754 quirk into a usable predicate.
NaN as a map key — silent data loss
m := map[float64]string{}
m[math.NaN()] = "first"
m[math.NaN()] = "second"
fmt.Println(len(m)) // 2 — two distinct NaN keys!
fmt.Println(m[math.NaN()]) // "" — lookup never matchesEach math.NaN() produces a NaN, and because NaN == NaN is false, the map treats every NaN insertion as a new, distinct key — len(m) is 2, not 1. Worse, a lookup m[math.NaN()] can never find any of them, so it returns the zero value "". A float64 map key that might ever be NaN is a latent data-loss bug. Validate with math.IsNaN before using a float as a key, or do not key on floats.
Float division by zero does not panic; integer division does
var fz float64 = 0
fmt.Println(1.0 / fz) // +Inf — no panic
fmt.Println(-1.0 / fz) // -Inf
fmt.Println(fz / fz) // NaN
var iz int = 0
fmt.Println(1 / iz) // panic: runtime error: integer divide by zeroOn the standard toolchain, floating-point division by zero produces the IEEE 754 result — ±Inf or NaN — and never panics; the spec leaves the panic-or-not question implementation-specific (see the callout below) but guarantees the result conforms to IEEE 754. Integer division by zero, by contrast, is an unconditional run-time panic mandated by the Go spec (“if the divisor is zero at run time, a run-time panic occurs” — see Integer Overflow and Division Semantics). The asymmetry is a frequent surprise: code that defends against integer divide-by-zero often forgets that the float path instead silently produces Inf/NaN that then contaminate every downstream computation.
Accumulation error compounds
sum := 0.0
for i := 0; i < 10; i++ {
sum += 0.1
}
fmt.Println(sum) // 0.9999999999999999 — not 1.0
fmt.Println(sum == 1.0) // falseEach += 0.1 adds the rounded value of 0.1 and rounds the running sum, so errors accumulate. Ten additions of “0.1” land at 0.9999999999999999. For exact decimal arithmetic — money, especially — never use float64. Use integer minor units (cents), or math/big.Rat / math/big.Float (math/big), or a fixed-point decimal library.
Failure Modes and Common Misunderstandings
x == y for floats. Almost always wrong for computed values. Use an epsilon, and prefer a relative epsilon scaled to magnitude. The only floats safe to compare with == are values you know are exactly representable (small integers, dyadic fractions) and have not been through lossy arithmetic.
if x == x to “validate” a float. Breaks on NaN. To assert a float is a real number, use !math.IsNaN(x) && !math.IsInf(x, 0).
Floats as map keys / in comparable structs. A NaN float key is unfindable and duplicates silently; a struct with a NaN float field compared by == is never equal to itself (see Comparing Structs and Interfaces). Avoid float map keys; if unavoidable, reject NaN first.
float64 for money. The number-one real-world float bug. 0.1 + 0.2 != 0.3 means currency totals drift by sub-cent amounts that accumulate and fail reconciliation. Use integer cents or math/big.
Expecting a constant expression to overflow to Inf. const x = 1e308 * 10 is a compile error, not +Inf — constants cannot be infinite. The same expression with float64 variables produces +Inf at runtime. Code that “works as a constant” can fail (or behave differently) when refactored to variables, and vice versa.
Assuming x*y + z rounds twice. The compiler may fuse it into a single full-precision multiply-add (FMA) that rounds once, on architectures that support it. The result can differ in the last bit from the round-twice value, and x*y - x*y is not guaranteed to be exactly 0.0. If you need a specific rounding, wrap the intermediate in an explicit conversion (float64(x*y)) to force a round and block fusion — the spec guarantees this defeats fusion (Arithmetic operators). Tests that assert exact float equality of a computed intermediate are the usual victims; they pass on a non-FMA build and fail on an FMA build, or vice versa.
float32 precision underestimated. float32 has only ~7 significant decimal digits (24-bit mantissa). Values that look fine in float64 lose precision badly when narrowed to float32. Default to float64 unless memory or hardware (GPUs, embedded) forces float32.
Comparing across float32/float64. Converting a float64 to float32 and back is lossy; a value round-tripped through float32 will usually not == the original float64.
Negative zero. IEEE 754 has both +0.0 and -0.0. They compare equal with == (so -0.0 == 0.0 is true), but they are distinct bit patterns and math.Signbit distinguishes them; 1/(-0.0) is -Inf while 1/(+0.0) is +Inf. Constants cannot denote -0.0, so it only arises at runtime.
Alternatives and When to Choose Them
float64— the default for scientific, statistical, geometric, and ML computation where small relative error is acceptable and performance matters. Compare with a relative epsilon.float32— only when memory footprint or specific hardware demands it; accept ~7-digit precision.- Integer minor units (cents, basis points) — the standard choice for money and any domain needing exact decimal arithmetic with a fixed scale. Fast, exact, and
==-comparable. math/big.Rat— arbitrary-precision rational numbers; exact for any computation expressible as fractions. Slower; use for correctness-critical exact arithmetic.math/big.Float— arbitrary (but configurable, finite) precision floating point; reduces but does not eliminate rounding. Use when you need more than 53 bits of mantissa.- A decimal library (e.g.
shopspring/decimal) — arbitrary-precision decimal arithmetic; the ergonomic choice for financial code that needs decimal semantics without hand-rolling integer cents.
Production Notes
The constant-vs-runtime fork is well documented in the Go Blog’s “Constants” post: Go’s untyped constants exist precisely so that math.Pi and friends carry full precision until forced into a concrete type, and so that constant overflow is a compile-time error rather than a silent runtime Inf. The practical takeaway for debugging: if an expression gives the “mathematically right” answer in one place and the “IEEE 754 answer” in another, check whether the operands are constants or variables — that is the fork, not nondeterminism.
The money bug is the most damaging in practice. Storing prices, balances, or tax as float64 guarantees that sums of many small amounts drift, and the drift surfaces as failed reconciliation, off-by-a-cent invoices, and audit findings. Mature financial Go codebases store money as int64 minor units or use a decimal type from the first commit; retrofitting it after float64 has spread through the schema and the API is expensive. The general rule: float64 is for measurement (where the input was already approximate), never for counting or accounting (where exactness is the whole point).
Uncertain
Verify: whether a conforming Go implementation could panic on (or otherwise diverge from
±Inf/NaNfor) floating-point division by zero. Reason: as of Go 1.26 (verified 2026-05-28) the spec’s “Arithmetic operators” section still reads “The result of a floating-point or complex division by zero is not specified beyond the IEEE-754 standard; whether a run-time panic occurs is implementation-specific” — so the spec explicitly does not forbid a panic, even though the officialgctoolchain never panics and always returns the IEEE 754 result. The wording’s accuracy is itself disputed: golang/go #43577 (opened Jan 2021, still open, labelled NeedsInvestigation, Backlog) argues IEEE 754 fully defines the result so the “implementation-specific” hedge is misleading. To resolve: watch #43577 for a spec edit that either removes the hedge (making±Inf/NaNa hard guarantee) or clarifies it; until then, treat non-gctoolchains’ behavior as unverified. Thegc-toolchain behavior shown above is confirmed and not in doubt. uncertain
See Also
- Integer Overflow and Division Semantics — sibling gotcha; integer divide-by-zero panics, unlike float
- Comparing Structs and Interfaces — why a NaN float field breaks struct
== - Map Iteration Order Randomization — sibling map gotcha; map keys rely on
== - Word Size and Architecture Portability —
float32/float64widths are fixed across architectures - Zero Values and Memory Initialization — the zero value of a float is
+0.0 - Go Type System — untyped constants and their default types
- Go Internals MOC — parent map, §13 Common Gotchas