GitHub - AlexandrosKyriakakis/zerodecimal: Zero-allocation, panic-free decimal arithmetic for HFT-grade Go. No big.Int, no GC pressure, fastest in its class.

GitHub

16 min read Original article ↗

Zero-allocation arithmetic and parsing, panic-free fixed-point decimals for latency-critical Go.

Why another decimal library

  • Zero-allocation numeric core. Parsing, arithmetic, comparison, rounding, conversions, and every Unmarshal/Scan path perform exactly zero heap allocations — success and error paths alike — enforced by testing.AllocsPerRun gates in the default test suite (alloc_test.go).
  • Lower pairwise geomean latency in the committed suite. The reproducibly configured hosted-runner cache-off comparison reports 35.10% lower sec/op than jokruger/dec128, 45.80% lower than quagmt/udecimal, and 92.71% lower than shopspring/decimal across each pair's common successful native-API rows (benchmarks/bench-vs-*.txt). These are microbenchmark-suite results, not production-throughput guarantees.
  • Independently oracled. Deterministic boundary suites and 49 fuzz targets use shopspring/decimal, math/big, and quagmt/udecimal where applicable. Arithmetic overflow classification is checked in both directions; codecs and SQL boundaries are covered by semantic and round-trip invariants.
  • Panic-free. Fallible operations return zero-allocation sentinel errors (errors.go) and the fuzz suite requires every target to be total — no input, including garbage binary payloads, may panic the library.

Install

go get github.com/AlexandrosKyriakakis/zerodecimal
import "github.com/AlexandrosKyriakakis/zerodecimal" // package zerodecimal

The module's language floor is Go 1.26. Production builds should use Go 1.26.5 or later so they include the standard-library security fixes in that patch. The library has zero runtime dependencies.

price, err := zerodecimal.NewFromString("99.99")
if err != nil {
	return err
}
qty := zerodecimal.NewFromInt(3)

total, err := price.MulRound(qty, 2, zerodecimal.ToNearestEven)
if err != nil {
	return err
}
fmt.Println(total)                // 299.97
fmt.Println(total.StringFixed(4)) // 299.9700

Runnable examples for parsing, arithmetic, rounding, JSON, and SQL live in example_test.go. Existing users should read MIGRATION.md before upgrading.

Design

type Decimal struct {
    coef u128  // |value| · 10^prec, 0 ≤ coef < 2^128
    neg  bool
    prec uint8 // fractional digits, 0..19
}

A Decimal is a pointer-free value: 24 bytes on targets with 8-byte maximum alignment and 20 bytes on gc's 32-bit max-align-4 ports (386, arm, mips, and mipsle). Copy it freely, compare it cheaply, pack it densely. The domain is |value| < 2^128 / 10^prec — up to 39 significant digits with up to 19 fractional. There is no big.Int anywhere in the package: every operation runs on fixed-width 128/256-bit integer math, so nothing can escape to the heap and magnitude overflow returns ErrOverflow instead of degrading into arbitrary-precision slowness. Legacy Mul, Div, and Avg may truncate digits beyond DefaultPrec; use the exact or directly rounded APIs below when discarded digits must be explicit. The zero value is canonical zero, ready to use; no operation produces negative zero.

Reciprocal division is the headline optimization. Decimal rescaling, rounding, formatting, and division all reduce to dividing by powers of ten, and zerodecimal never asks the hardware divider to do it: 64-bit dividends use precomputed Granlund–Montgomery–Warren multiply-high magics, and 128/256-bit dividends chain Möller–Granlund 2-by-1 steps off a precomputed reciprocal table (div10.go, tables generated and re-proven against bits.Div64 and big.Int in tables_test.go). A multiply-high plus a shift replaces an 18-cycle DIV — and for 128-bit dividends, two dependent DIVs. This is a major source of rescaling headroom; competitor impact is measured by the committed suite rather than inferred from instruction counts.

Div uses adaptive precision: the result is the exact quotient truncated at the largest precision ≤ DefaultPrec (19 by default) whose coefficient still fits 128 bits, so huge quotients degrade precision gracefully and ErrOverflow is reserved for integer quotients that genuinely exceed 2^128.

Because == compares representations, an arithmetic result of 1.50 differs from a parsed 1.5 under ==; use Equal or Cmp for numeric comparison. Parsing trims trailing fractional zeros; arithmetic never does (it would tax the hot path); formatting trims at output. Trim canonicalizes a value on demand — equal numbers become identical under == and as map keys — and Rescale sets an exact representation precision (e.g. 1.50 for a two-decimal wire format), rounding ties to even when lowering.

The exported Zero and One variables remain deprecated compatibility shims. They are mutable Go variables and must not be assigned to; use Decimal{} and NewFromInt(1) instead. Package internals do not read them.

Exact and directly rounded arithmetic

Mul and Div retain their fast compatibility semantics and can discard digits without returning ErrInexact. For calculations where loss must be explicit, use:

Intent Multiplication/division Mean
Require an exact Decimal representation MulExact, DivExact AvgExact
Round once to a requested scale MulRound, DivRound AvgRound

Exact operations return ErrInexact when nonzero digits would be discarded, ErrUnderflow for a nonzero result below 10^-MaxPrec, and ErrOverflow for an excessive magnitude. Direct-round operations preserve the full remainder through one rounding decision and accept ToNearestAway, ToNearestEven, AwayFromZero, TowardZero, TowardPositive, or TowardNegative. errors.Is(ErrUnderflow, ErrInexact) is true.

Sum and the mean operations use a fixed-width wide signed accumulator, so cancellation is independent of operand order. QuoRem and Mod implement truncated division and correctly return quotient zero when a precision-aligned divisor is wider than the 128-bit numerator.

Error model

All sentinels live in errors.go, are returned bare on hot paths, and match with errors.Is. database/sql may wrap scanner errors, and Scan's precomputed bool and time.Time unsupported-type errors wrap ErrScanType. Constructors and legacy arithmetic operations have panicking twins for call sites with proven bounds; rows marked below have none.

Operation Possible sentinels Panicking twin
New ErrOverflow, ErrPrecOutOfRange MustNew
NewFromString, ParseBytes ErrEmptyString, ErrMaxStrLen, ErrInvalidFormat, ErrOverflow, ErrPrecOutOfRange RequireFromString
NewFromStringTrunc, ParseBytesTrunc ErrEmptyString, ErrMaxStrLen, ErrInvalidFormat, ErrOverflow
NewFromFloat, NewFromFloat32 ErrInvalidFloat, ErrOverflow, ErrPrecOutOfRange RequireFromFloat
NewFromHiLo ErrPrecOutOfRange
Add, Sub, Mul ErrOverflow MustAdd, MustSub, MustMul
Div ErrDivideByZero, ErrOverflow MustDiv
MulExact, DivExact, AvgExact ErrInexact, ErrUnderflow, ErrOverflow; division also ErrDivideByZero
MulRound, DivRound, AvgRound ErrPrecOutOfRange, ErrInvalidRoundingMode, ErrOverflow; division also ErrDivideByZero
QuoRem, Mod ErrDivideByZero, ErrOverflow MustQuoRem, MustMod
Sum, Avg ErrOverflow MustSum, MustAvg
Rescale ErrPrecOutOfRange, ErrOverflow MustRescale
IntPart ErrIntPartOverflow
UnmarshalText, UnmarshalJSON parse sentinels; JSON null into Decimal returns ErrJSONNull
UnmarshalBinary ErrInvalidBinaryData
Decimal.Scan, NullDecimal.Scan parse sentinels, ErrInvalidFloat, ErrScanType; required Decimal also returns ErrScanNil
StrictSQLDecimal.Scan, StrictNullDecimal.Scan parse sentinels, ErrScanFloat, ErrScanType; required StrictSQLDecimal also returns ErrScanNil
Nil decoder/scanner receiver ErrNilReceiver

Everything else is infallible: NewFromInt/NewFromInt32/NewFromUint64, Neg, Abs, Sign, the Is* predicates, Cmp and the comparison family, Min/Max, the original rounding family, Trim, Prec, ToHiLo, String, StringFixed, AppendFixed, and InexactFloat64. AppendText, AppendBinary, the Marshal* methods, and Value return an error only to satisfy their interfaces — it is currently always nil.

Allocation guarantees

alloc_test.go enforces exact testing.AllocsPerRun contracts on success and error paths:

Allocations Operations
exactly 0 Parsing; legacy, exact, direct-round, and aggregate arithmetic; comparison and rounding; Trim; decode and Scan paths; Append* with sufficient caller capacity
exactly 1 StringFixed for every uint8 places value; MarshalText, MarshalJSON, and MarshalBinary for their returned slice
normally 1 Uncached multi-byte String results; Go may serve a one-byte string from its static table
exactly 2 in the gated uncached multi-byte case Value: canonical string plus boxing into driver.Value
exactly 0 when the opt-in cache is enabled and hits String and valid SQL Value paths for cache-window values

The string/driver.Value cache is absent by default. Building with zerodecimal_strcache eagerly materializes values in -1000.00..+1000.00 at up to two decimal places. Hits avoid result allocation, but the cache adds startup work, resident memory, heap objects, and GC roots; enable it only when representative measurements justify it. zerodecimal_nostrcache forces the cache off and wins if both cache tags are present.

Parsing rules

Grammar: ['+'|'-'] digits ['.' digits] [('e'|'E') ['+'|'-'] digits], ASCII only, at most 200 bytes.

Accepted:

  • plain literals: "123", "-4.20", "+1", redundant zeros ("00012.3400"12.34)
  • scientific notation: "1.23e4"12300, "1E-7"0.0000001
  • the maximum coefficient and equivalent noncanonical spellings, including "340282366920938463463374607431768211455.0" and "3402823669209384634633746074317682114550e-1"
  • redundant fractional zeros beyond 19 places, such as "1.00000000000000000000", because canonical precision is checked after exponent folding and trailing-zero removal

Rejected:

  • ""ErrEmptyString; input over 200 bytes → ErrMaxStrLen
  • "1." and ".1"ErrInvalidFormat: both sides of the dot need a digit, as do ".", "-", "1..2", "1e", and "1e+"
  • whitespace, underscores, non-ASCII digits, "NaN", and "Inf"ErrInvalidFormat
  • an effective precision above 19 with discarded nonzero digits → ErrPrecOutOfRange; a canonical coefficient above 2^128−1 → ErrOverflow

The Trunc variants (NewFromStringTrunc, ParseBytesTrunc) replace ErrPrecOutOfRange with truncation toward zero at 19 fractional digits. They still enforce the grammar, input bound, and final coefficient range. Results are canonical and parsing never allocates, including on failure.

Rounding modes

places counts fractional digits; places ≥ d.Prec() returns d unchanged. The whole original family is infallible — the increment can never overflow — and rounding a negative value to zero yields the canonical unsigned zero.

Method Mode 2.5 3.5 -2.5
Round(0) half away from zero (shopspring Round) 3 4 -3
RoundBank(0) half to even (banker's) 2 4 -2
RoundUp(0) away from zero 3 4 -3
RoundDown(0) / Truncate(0) toward zero 2 3 -2
RoundCeil(0) toward +∞ 3 4 -2
RoundFloor(0) toward −∞ 2 3 -3

Floor() and Ceil() are RoundFloor(0) and RoundCeil(0). Every mode is pinned tie-by-tie against its shopspring equivalent in crosscheck_test.go and fuzzed in fuzz_test.go.

JSON and SQL boundaries

MarshalJSON emits a quoted canonical decimal. UnmarshalJSON accepts bare JSON numbers and quoted decimal strings; bare input follows JSON number syntax, while quoted content is semantically unescaped before decimal parsing. Malformed escapes, invalid UTF-8, and invalid surrogate pairs are rejected. Decimal.UnmarshalJSON(null) returns ErrJSONNull and leaves the receiver unchanged; use NullDecimal or StrictNullDecimal when null is valid.

For SQL NUMERIC/DECIMAL, StrictSQLDecimal is the recommended required scanner and StrictNullDecimal the nullable scanner. They accept exact text, byte, and integer sources but reject float32/float64 provenance with ErrScanFloat. The legacy Decimal and NullDecimal scanners continue to accept float sources for compatibility. This is a Go-side source-type policy: a driver that renders a floating column as text cannot be distinguished from exact decimal text. Exercise NUMERIC/DECIMAL schemas, casts, and the actual driver protocol in integration tests. Do not bind a nil *StrictSQLDecimal for a required parameter; database/sql converts it to SQL NULL without invoking Value.

Benchmarks

Pairwise native-API geomean latency normalized to zerodecimal at 1.0x; shorter is faster

Collected from clean source commit 9aaeadb07e65d71e2e6e9cc92b0af6f3d5c10c1d on darwin/arm64, Apple M1 (Virtual), Go 1.26.5, cache off, 100 ms × 10 samples. Libraries ran in a fixed order, so treat this as a transparent microbenchmark snapshot rather than production-performance proof.

The comparative suite lives in benchmarks/ — a separate Go module, so the competitor dependencies never touch the library's go.mod. Full committed results: bench-vs-dec128.txt, bench-vs-udecimal.txt, bench-vs-govalues.txt, bench-vs-shopspring.txt, bench-vs-alpacadecimal.txt, bench-vs-ericlagergren.txt; the raw unified samples and provenance with input hashes are also committed. Methodology and the deliberate semantic asymmetries are documented in benchmarks/README.md. The chart above is regenerated from those files with make -C benchmarks chart; its ratios compare each competitor only with the rows that both libraries successfully implement.

Against jokruger/dec128 — the nearest pairwise geomean competitor in this run and also a 128-bit fixed-point design — the artifact reports 74 faster rows, 12 statistical ties, and 2 slower rows across 88 common op × shape rows. The slower rows are MarshalJSON/large at +32.91% and MarshalBinary/large at +7.01%:

                                │    dec128     │             zerodecimal              │
                                │    sec/op     │    sec/op     vs base                │
Add/typical_price-3                9.879n ± 56%   3.385n ± 12%  -65.73% (p=0.000 n=10)
Mul/typical_price-3                4.347n ± 13%   2.752n ±  5%  -36.69% (p=0.000 n=10)
Div/typical_price-3                13.96n ± 61%   10.28n ± 50%        ~ (p=0.063 n=10)
QuoRem/typical_price-3             8.891n ±  4%   3.903n ± 14%  -56.10% (p=0.000 n=10)
Cmp/typical_price-3                4.362n ±  3%   2.330n ±  2%  -46.57% (p=0.000 n=10)
Parse/typical_price-3              13.18n ±  8%   13.01n ± 15%        ~ (p=0.542 n=10)
String/typical_price-3             37.77n ± 35%   34.66n ± 11%        ~ (p=0.105 n=10)
geomean                            14.73n          9.561n        -35.10%

Against quagmt/udecimal, the artifact reports 87 faster rows, 3 statistical ties, and no slower rows across 90 common rows:

                                │   udecimal    │             zerodecimal              │
                                │    sec/op     │    sec/op     vs base                │
Add/typical_price-3                7.474n ± 14%   3.385n ± 12%  -54.70% (p=0.000 n=10)
Mul/typical_price-3                7.656n ±  6%   2.752n ±  5%  -64.05% (p=0.000 n=10)
Div/typical_price-3                19.24n ± 27%   10.28n ± 50%  -46.57% (p=0.002 n=10)
QuoRem/typical_price-3            17.905n ±  7%   3.903n ± 14%  -78.20% (p=0.000 n=10)
Cmp/typical_price-3                5.763n ±  2%   2.330n ±  2%  -59.56% (p=0.000 n=10)
Parse/typical_price-3              20.47n ± 18%   13.01n ± 15%  -36.47% (p=0.000 n=10)
String/typical_price-3             42.34n ± 35%   34.66n ± 11%  -18.14% (p=0.001 n=10)
geomean                            17.62n         9.550n        -45.80%

Against shopspring/decimal, the artifact reports 85 faster rows, no statistical ties, and no slower rows across 85 common rows:

                                │   shopspring   │             zerodecimal              │
                                │     sec/op     │    sec/op     vs base                │
Add/typical_price-3                71.855n ± 11%   3.385n ± 12%  -95.29% (p=0.000 n=10)
Mul/typical_price-3                54.275n ± 15%   2.752n ±  5%  -94.93% (p=0.000 n=10)
Div/typical_price-3                707.25n ± 12%   10.28n ± 50%  -98.55% (p=0.000 n=10)
QuoRem/typical_price-3            171.700n ±  7%   3.903n ± 14%  -97.73% (p=0.000 n=10)
Cmp/typical_price-3                 4.753n ±  0%   2.330n ±  2%  -50.97% (p=0.000 n=10)
Parse/typical_price-3              119.00n ± 11%   13.01n ± 15%  -89.07% (p=0.000 n=10)
String/typical_price-3             167.10n ± 31%   34.66n ± 11%  -79.26% (p=0.000 n=10)
geomean                             125.7n         9.170n        -92.71%

The full reports include B/op and allocs/op for every row. Do not infer a universal allocation ranking from the latency chart: most numeric-core rows are zero-allocation, while string-returning and SQL Value paths have the documented cache-off allocation floors below.

Known trade-offs

Allocation floors accepted by design (from benchmarks/README.md):

  • String: normally 1 allocation in the default cache-off build for a multi-byte result. Go may serve one-byte strings from a runtime static table.
  • MarshalText/MarshalJSON/MarshalBinary: 1 allocation for the caller-owned result slice.
  • Value: 2 allocations in the gated uncached multi-byte case: the canonical string plus boxing it into driver.Value.
  • With zerodecimal_strcache, eligible String and Value hits allocate nothing; benchmark cache-on and cache-off as separate populations.

PGO

PGO attaches to binaries, not libraries — so zerodecimal cannot ship a profile, but an application build can use one. The hot arithmetic paths avoid interfaces and indirect calls, and the slow arms (addUnaligned, mulSlow, the multi-limb division bodies) are deliberately outlined into small functions that profile-driven inlining can promote into hot call sites past the default inlining budget.

  1. Collect a CPU profile from production or a representative load: pprof.StartCPUProfile / curl .../debug/pprof/profile > default.pgo.
  2. Drop it at your main package root as default.pgo (picked up by go build automatically, i.e. -pgo=auto) or pass -pgo=/path/to/pprof.
  3. Rebuild and benchmark the resulting application.

The committed benchmarks/bench-pgo.txt is a reproducibly configured hosted-runner experiment, but deliberately in-sample: the synthetic benchmark binary is rebuilt against its own profile. It reports a 13.62% lower sec/op geomean, with 78 faster rows, 10 statistical ties, and 2 slower rows. The slower rows are RoundBank/small_int at +0.86% and NewFromFloat/small_int at +6.77%. This illustrates compiler opportunity in the harness; it does not predict the benefit of an application profile:

                                │    default    │                  pgo                  │
                                │    sec/op     │    sec/op      vs base                │
Add/typical_price-3                2.760n ±  6%   2.211n ±  1%  -19.88% (p=0.000 n=10)
Sub/typical_price-3                2.932n ±  5%   2.458n ±  9%  -16.17% (p=0.000 n=10)
Mul/large-3                        4.803n ±  2%   3.893n ±  2%  -18.94% (p=0.000 n=10)
Div/typical_price-3                7.475n ±  3%   5.777n ±  7%  -22.71% (p=0.000 n=10)
QuoRem/typical_price-3             3.431n ±  5%   2.992n ±  3%  -12.80% (p=0.000 n=10)
RoundBank/typical_price-3          3.394n ±  1%   3.055n ±  2%  -10.00% (p=0.000 n=10)
Cmp/typical_price-3                2.194n ±  3%   2.161n ±  5%        ~ (p=0.079 n=10)
geomean                            9.099n         7.860n        -13.62%

On amd64 deployments also consider GOAMD64=v3: the BMI2/ADX instructions materially speed the bits.Mul64/bits.Add64 carry chains that dominate the primitives (arm64 needs no flag).

Build tags

Tag Effect
zerodecimal_prec9 Sets the compile-time DefaultPrec to 9 fractional digits
zerodecimal_prec12 Sets DefaultPrec to 12 fractional digits
zerodecimal_strcache Enables the eager small-value string/driver.Value cache
zerodecimal_nostrcache Forces the cache off

The precision tags are mutually exclusive. They change the truncation cap for legacy Mul, Div, and Avg; strict parsing, MaxPrec, exact APIs, and direct-round APIs remain at 19 places. zerodecimal_nostrcache wins if both cache tags are present.

CI runs the full suite across all 12 precision/cache combinations and on Linux/386. make test-matrix also cross-compiles and vets Linux 386 and arm; compiler-shape gates apply only to 64-bit targets where those budgets were established.

How correctness is enforced

  • Deterministic cross-check in the default suite (crosscheck_test.go): arithmetic, comparison, rounding, parsing, and formatting results are checked against shopspring/decimal's unbounded big.Int arithmetic over an exhaustive boundary-value pair sweep plus fixed-seed boundary-biased random pairs. The overflow oracle is iff: every ErrOverflow must be proven exact and every fitting result must be returned — a spurious error fails as loudly as a wrong value.
  • 49 differential fuzz targets (make fuzz-all): legacy, exact, direct-round, and aggregate arithmetic; parsing and canonicalization; text, JSON, binary, and SQL boundaries; nil receivers; cache modes; formatting; float conversion; division primitives; and structural invariants. shopspring/decimal, math/big, and quagmt/udecimal serve as independent oracles where applicable.
  • 6.5+ million fixed-seed primitive cases: the u128/u256 primitives and every reciprocal-division path are verified against bits.Div64 and big.Int at carry, limb, power-of-ten, and exact-overflow boundaries plus millions of shaped random cases per run (u128_test.go, u256_test.go, div10_test.go), and the generated magic-constant tables are recomputed from their definitions in tables_test.go.
  • Allocation, architecture, and codegen gates assert exact allocation counts, 32/64-bit layout assumptions, bounds-check elimination, and the 64-bit inlining shape of hot paths from the compiler's own -m=2 report.

Limitations vs shopspring/decimal

  • Bounded domain. |value| < 2^128 / 10^prec — at most 39 significant digits and 19 fractional digits. There is no arbitrary-precision fallback; magnitude overflow returns ErrOverflow, while legacy Mul and Div may truncate a result needing more than DefaultPrec fractional digits.
  • places is uint8. Negative places (rounding at tens/hundreds positions, shopspring's Round(-2)) are unsupported by design — this is what keeps the original rounding family infallible.
  • Legacy division precision is compile-time. Div truncates at adaptive precision up to DefaultPrec (19, or 9/12 via build tags); there is no runtime DivisionPrecision knob. DivRound selects an explicit output scale per call.
  • No Pow, Sqrt, or transcendental functions yet.
  • Stricter parsing. "1." and ".1" are rejected; shopspring accepts both.
  • No exotic float forms. NewFromFloat rejects NaN/±Inf with ErrInvalidFloat rather than panicking, and converts via the shortest decimal representation (like shopspring) — floats outside the domain error instead of rounding silently.

Production use

The library's tests and microbenchmarks do not certify an application. For a money-moving deployment, pin the Go version and build tags; use exact or direct-round APIs with reviewed scales and rounding modes; test the real SQL driver/schema and JSON models; compare critical formulas against an independent implementation; and measure throughput, p99/p999 latency, startup, RSS, and GC behavior on target hardware before rollout.

Acknowledgements

Almost all of zerodecimal is original — the zero-allocation u128/u256 primitives, reciprocal-multiply division by powers of ten, the SWAR parse path, and the width-dispatched 128/64 division are its own work. The one ported component is float formatting:

shopspring/decimal and quagmt/udecimal serve as correctness oracles; the comparative benchmark harness additionally measures against jokruger/dec128, govalues/decimal, alpacadecimal, and ericlagergren/decimal.

License

MIT. zerodecimal incorporates BSD-3-Clause code from the Go standard library; the required notice is reproduced in THIRD_PARTY_NOTICES.md.