— notes

Floating-Point Tolerance Testing in Go

1 min read#go

Last Tuesday, I was knee-deep in a financial calculation service when my tests started failing. You know that sinking feeling when 0.1 + 0.2 doesn’t equal 0.3? That was my afternoon.

This got me thinking about when we actually need tolerance-based comparisons versus when we’re just cargo-culting best practices.

graph LR
    A["0.1 + 0.2"] --> B["IEEE 754"]
    B --> C["0.30000...04"]
    D["0.3"] --> E["IEEE 754"]
    E --> F["0.29999...99"]
    C --> G["Gap = epsilon"]
    F --> G

When tolerance matters

I was working on a discount calculation system:

func calculateDiscount(price, rate float64) float64 {
    return price * rate
}

func TestDiscountCalculation(t *testing.T) {
    price := 29.99
    rate := 0.15
    expected := 4.4985
    result := calculateDiscount(price, rate)
    if result != expected {
        t.Errorf("got %v, want %v", result, expected)
    }
}

With tolerance, you’d handle it like this:

func almostEqual(a, b, tolerance float64) bool {
    return math.Abs(a-b) <= tolerance
}

func TestDiscountCalculationWithTolerance(t *testing.T) {
    price := 29.99
    rate := 0.15
    expected := 4.4985
    result := calculateDiscount(price, rate)
    if !almostEqual(result, expected, 1e-9) {
        t.Errorf("got %v, want %v", result, expected)
    }
}

When you DON’T need tolerance

Some comparisons are fine without tolerance:

if value == 0.0 { } // Zero has exact representation
if math.IsNaN(result) { } // Special values
if result == math.Inf(1) { } // Infinity comparisons

If your floats haven’t been through different computational paths, exact comparison often works fine.

flowchart TD
    A["Comparing floats?"] --> B{"Result of\narithmetic?"}
    B -- Yes --> C["Use epsilon"]
    B -- No --> D{"Integer-\nconvertible?"}
    D -- Yes --> E["No epsilon needed"]
    D -- No --> F{"Currency?"}
    F -- Yes --> G["Use integer cents"]
    F -- No --> C

A practical helper

Here’s a utility I use across projects:

const DefaultFloatTolerance = 1e-9

func FloatEquals(a, b float64) bool {
    return FloatEqualsWithTolerance(a, b, DefaultFloatTolerance)
}

func FloatEqualsWithTolerance(a, b, tolerance float64) bool {
    if math.IsNaN(a) && math.IsNaN(b) {
        return true
    }
    if math.IsInf(a, 0) && math.IsInf(b, 0) {
        return math.Signbit(a) == math.Signbit(b)
    }
    return math.Abs(a-b) <= tolerance
}

Use tolerance when your floats have been through computational journeys. Skip it for direct assignments, constants, and same-path calculations.