decint

decint represents decimal values as fixed-point integers scaled by 1e6, with a documented safe range and half-away-from-zero rounding.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/decint"

Stores a decimal with up to six fractional digits as an int64 scaled by 1e6, and converts to and from float64 and string.

The problem it refuses to have

0.1 + 0.2 != 0.3 in binary floating point, and every language with IEEE 754 doubles inherits it. For a rendering coordinate nobody cares. For money, a quantity that must reconcile, or anything that gets compared for equality after a round trip through a database and a JSON payload, the accumulated error is a defect.

The usual answers are an arbitrary-precision decimal type, which costs allocations and a dependency, or scaled integers, which cost a fixed precision ceiling. decint takes the second: pick six decimal places, multiply by 1e6, and store an ordinary int64. Comparison is integer comparison. Serialisation is an integer. Transport is an integer. None of those steps can introduce a rounding error, because there is no fraction left to round.

Six places is fixed, not configurable. It covers currency minor units with four digits to spare, and percentage and rate values past the precision most systems record.

Where the safe range comes from

MaxFloat is 2^33, which is 8,589,934,592. That number looks arbitrary and is not.

A float64 has 52 bits of mantissa. The gap between adjacent representable values, the ULP, doubles as the exponent grows. Below 2^33 the ULP is under 1e-6, so every six-decimal step has a distinct float64. Above it the ULP crosses 1e-6 and the sixth decimal digit stops being representable at all: two values that differ in that digit become the same float64 before your code ever sees them.

The package excludes that range rather than rounding into it, which is the right call. Silently rounding would mean the boundary between exact and approximate is invisible at the call site.

MaxInt bounds the scaled integer side for the same reason.

Rounding is half away from zero

Conversion from float64 scales by 1e6 and rounds to nearest, with ties going away from zero. 2.5 becomes 3; -2.5 becomes -3. A seventh decimal digit is rounded rather than truncated.

Go’s own math.Round uses the same rule. The rounding is applied to the seventh decimal digit and beyond, so FloatToInt(1.23456749) yields 1234567 and FloatToInt(1.23456750) yields 1234568. Half away from zero is symmetric about zero, which keeps FloatToInt(-x) == -FloatToInt(x) exact for every input in the safe range.

Conversion costs are asymmetric

Conversionns/opB/opallocs/op
IntToFloat0.4700
UintToFloat0.5200
FloatToInt2.4700
FloatToUint3.6600
StringToInt31.400
StringToUint31.100
UintToString111323
IntToString245323

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/decint/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Parsing runs eight times cheaper than formatting. Formatting is the only operation here that allocates, and IntToString costs more than twice UintToString because of the sign handling.

Every other conversion runs allocation-free, so the type carries no per-row garbage collection cost.

The unsigned helpers clamp

FloatToUint and the other unsigned conversions clamp non-positive input to zero rather than returning an error or wrapping. Clamping keeps quantities that cannot be negative inside their domain without a guard at each call site. It also means a value of -5 reaching the conversion becomes 0 rather than an error, so the signed helpers are the ones that surface a negative input.

Invalid numeric strings do return an explicit parse error, so the string path fails loudly.

Example

v, err := decint.StringToInt("123456")
if err != nil {
    log.Fatal(err)
}

fmt.Println(v)

// Output:
// 123456000000

The six zeros are the 1e6 scaling. Full source is in example_int64_test.go, with the unsigned equivalents in example_uint64_test.go. More runnable examples are on pkg.go.dev.

Dependencies

This package reaches no external module: it uses only the Go standard library.