Numbers That Lie
The problem
Run this:
package main
import "fmt"
func main() {
fmt.Println(0.1 + 0.2)
}You get:
0.30000000000000004That is not a Go bug. Python does it. JavaScript does it. C does it. Your phone's calculator does it and hides the evidence. It's a property of how computers store decimal numbers, and if you're going to write code that touches money, you need to understand it rather than work around it by accident.
Why it happens
Computers store numbers in binary - powers of two. In base ten, we write fractions as tenths, hundredths, thousandths. In binary, they're halves, quarters, eighths, sixteenths.
Some fractions convert perfectly. 0.5 is exactly one half. 0.25 is exactly one quarter. Fine.
But 0.1 in binary is 0.0001100110011001100110011... - the pattern repeats forever, exactly the way 1/3 in decimal is 0.3333... forever. The computer has finite space (64 bits for a float64), so it stores the closest value it can and stops.
That stored value isn't quite 0.1. It's fractionally off. Add two fractionally-off numbers and the error becomes visible.
what you wrote what's stored
0.1 → 0.1000000000000000055511151231257827...
0.2 → 0.2000000000000000111022302462515654...
─────────────────────────────────────
sum 0.3000000000000000444089209850062616...
which prints as 0.30000000000000004The rule that follows: never test floating-point numbers for exact equality.
if 0.1+0.2 == 0.3 { // false! never do this
fmt.Println("equal")
}Compare against a tolerance instead:
import "math"
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}The money rule
Here's the consequence that matters, and it's the reason this chapter exists:
Never store money in a float.
Not account balances, not order quantities, not cumulative P&L. Every real trading system, every bank, every accounting package stores money as integers of the smallest unit - cents, satoshis, pips.
Instead of float64 euros:
balance := 1000.00 // wrong for moneyuse int64 cents:
balanceCents := int64(100000) // €1000.00, exactlyIntegers are exact. There is no rounding error, ever. Adding a million transactions in cents gives you precisely the right answer; adding a million transactions in floats gives you something that drifts.
The cost is that you have to convert for display:
func formatCents(cents int64) string {
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
}
fmt.Println(formatCents(100000)) // 1000.00
fmt.Println(formatCents(4530)) // 45.30Notice cents/100 and cents%100 - integer division gives whole euros, remainder gives the leftover cents. That "trap" from Chapter 1 is now doing useful work. %02d means "an integer, at least 2 digits, pad with zeros," so 5 cents prints as 05 rather than 5.
So when is a float fine?
Floats are fine for anything where a rounding error in the fifteenth decimal place doesn't matter:
- Prices used for calculation and display
- Indicator values - moving averages, ATR, volatility
- Percentages and ratios
- Statistical output
Floats are not fine for:
- Account balances
- Realised profit and loss you're accumulating
- Order quantities on exchanges with strict lot sizes
- Anything you'll compare for exact equality
- Anything that has to reconcile against someone else's number to the penny
The distinction is roughly: is this a measurement, or is it a count of something? Measurements can be approximate. Counts must be exact.
Integer limits
Integers are exact but not infinite. An int64 holds roughly ±9.2 quintillion, which sounds like plenty, and is - until you're counting satoshis in a large position, or nanoseconds since 1970 multiplied by something.
import "math"
fmt.Println(math.MaxInt64) // 9223372036854775807Exceeding it doesn't produce an error. It wraps around to a large negative number, silently:
var x int64 = math.MaxInt64
x = x + 1
fmt.Println(x) // -9223372036854775808This is overflow, and it's a genuine source of catastrophic bugs in financial code, because it's silent and the resulting number looks like a real value.
The types you'll actually use
Go has many number types. You need four.
| Type | Use it for |
|---|---|
int | counting, loop indices, array positions |
int64 | money in cents, timestamps, anything needing an explicit width |
float64 | prices, indicators, statistics |
bool | true/false |
Default to int for counting and float64 for measuring. Reach for int64 when you're representing money or a timestamp. Ignore the rest until you need them.
Run It Yourself
Everything above is a claim. Here's the whole chapter as one program you can actually run. Save it as money.go and go run money.go.
package main
import (
"fmt"
"math"
)
func nearlyEqual(a, b, tolerance float64) bool {
return math.Abs(a-b) < tolerance
}
func formatCents(cents int64) string {
sign := ""
if cents < 0 {
sign = "-"
cents = -cents
}
return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}
func main() {
fmt.Println("=== 1. floats are approximate ===")
fmt.Printf("0.1 + 0.2 = %.20f\n", 0.1+0.2)
fmt.Printf("0.1 + 0.2 == 0.3 ? %t\n", 0.1+0.2 == 0.3)
fmt.Printf("nearlyEqual ? %t\n", nearlyEqual(0.1+0.2, 0.3, 1e-9))
fmt.Println("\n=== 2. the drift is real ===")
floatTotal := 0.0
var centsTotal int64
for i := 0; i < 1_000_000; i++ {
floatTotal += 0.01 // one cent, as a float
centsTotal += 1 // one cent, as an integer
}
fmt.Printf("float sum = %.10f\n", floatTotal)
fmt.Printf("integer sum = %s\n", formatCents(centsTotal))
fmt.Printf("difference = %.10f\n",
floatTotal-float64(centsTotal)/100)
fmt.Println("\n=== 3. integer division does useful work ===")
for _, c := range []int64{100000, 4530, 5, -4530} {
fmt.Printf("%8d cents = %s\n", c, formatCents(c))
}
fmt.Println("\n=== 4. overflow is silent ===")
var x int64 = math.MaxInt64
fmt.Printf("MaxInt64 = %d\n", x)
fmt.Printf("MaxInt64 + 1 = %d <-- no error, just wrong\n", x+1)
}Expected output (the drift figure will differ slightly on your machine):
=== 1. floats are approximate ===
0.1 + 0.2 = 0.30000000000000004441
0.1 + 0.2 == 0.3 ? false
nearlyEqual ? true
=== 2. the drift is real ===
float sum = 10000.0000000181
integer sum = 10000.00
difference = 0.0000000181
...Now break it on purpose. Three experiments:
- Change the loop to 10 million iterations. Does the drift grow proportionally?
- Change
0.01to0.5and re-run. Why does the drift vanish? (Chapter 2 told you - a half is exact in binary.) - Remove the
-handling fromformatCentsand pass it-4530. What do you get, and why?
Exercises
2.1 Predict, then check:
fmt.Println(0.1 + 0.2 == 0.3)
fmt.Println(0.5 + 0.25 == 0.75)
fmt.Println(1.0 / 3.0 * 3.0 == 1.0)Why does the second one behave differently from the first?
2.2 Write nearlyEqual(a, b, tolerance float64) bool and use it to check whether 0.1+0.2 and 0.3 are equal within 1e-9.
2.3 Write formatCents(cents int64) string handling negatives correctly. formatCents(-4530) should give -45.30, not -45.-30. (Hint: work out the sign first, then format the absolute value.)
2.4 You buy 3 shares at €45.30, 7 at €45.35, and 2 at €45.28. Compute the total cost two ways - once with float64 euros, once with int64 cents - and print both to four decimal places. Do they agree?
2.5 A position accumulates €0.01 profit, 1,000,000 times. Compute the total as a float64 sum in a loop, and as an int64 sum of cents. Print both. How far apart are they? (You'll need Chapter 4's loops - either skip ahead or come back to this one.)
Solutions
2.1 false, true, and the third depends on your machine but is usually false. The second works because 0.5, 0.25 and 0.75 are all exact sums of powers of two - a half, a quarter, and three quarters convert to binary perfectly. 0.1 and 0.3 do not.
2.2
func nearlyEqual(a, b, tolerance float64) bool {
return math.Abs(a-b) < tolerance
}
fmt.Println(nearlyEqual(0.1+0.2, 0.3, 1e-9)) // true2.3
func formatCents(cents int64) string {
sign := ""
if cents < 0 {
sign = "-"
cents = -cents
}
return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}2.4
floatTotal := 3*45.30 + 7*45.35 + 2*45.28
centsTotal := int64(3*4530 + 7*4535 + 2*4528)
fmt.Printf("float: %.4f\n", floatTotal)
fmt.Printf("cents: %.4f\n", float64(centsTotal)/100)They agree to four decimal places here, but the float version is already carrying a tiny error you can see if you print fifteen decimals. The point isn't that one purchase breaks it - it's that a million do.
2.5 The float sum drifts measurably from 10000.00; the integer sum is exactly 1000000 cents. This is the whole chapter in one experiment, and it's worth actually running.