High performance 128-bit fixed-point decimal numbers in Go, built for financial and banking arithmetic.
- High performance
- Zero dependencies
- Minimal or zero memory allocation
- Scale up to 19 decimal places
- Fixed 24-byte layout with no indirection (128-bit coefficient, scale, sign/state)
- No panic or error arithmetics (use NaN instead)
- Immutability (methods return new instances)
- Basic arithmetic operations required for financial calculations (specifically for banking and accounting)
- Easy to use
- Easy to integrate with external systems (e.g. databases, accounting systems, JSON, etc.)
- Financially correct rounding
- Correct comparison of numbers encoded in different scales (e.g. 1.0 == 1.00)
- Correct handling of NaN values (e.g. NaN + 1 = NaN)
- Conversion to canonical representation (e.g. 1.0000 -> 1)
- Conversion to fixed string representation (e.g. 1.0000 -> "1.0000")
- Conversion to human-readable string representation (e.g. 1.0000 -> "1")
- Scientific notation: parsed by
FromString, printed on request (e.g. "1.5e3" -> 1500, 12345 -> "1.2345e+4") - Per-call scale for division and square root (
DivAtScale,SqrtAtScale), no reliance on global state - Configurable SQL
NULL/ JSONnullhandling that round-trips (SetNullValue)
Run go get github.com/jokruger/dec128
This library requires Go version >=1.24 (as declared in go.mod).
make test # run tests with coverage
make view # open the HTML coverage report
make lint # run golangci-lint (installs the pinned version on first use)
make lint-fix # run golangci-lint with --fixLinting is configured in .golangci.yml and enforced in CI by
.github/workflows/lint.yml.
https://pkg.go.dev/github.com/jokruger/dec128
package main
import (
"fmt"
"github.com/jokruger/dec128"
)
func main() {
principal := dec128.FromString("1000.00")
annualRate := dec128.FromString("5.0")
days := 30
// multiply first and divide once at the end: dividing early throws away digits
// that the later multiplications would have needed
accrued := principal.Mul(annualRate).MulInt(days).Div(dec128.FromInt64(36500))
if err := accrued.ErrorDetails(); err != nil {
panic(err) // a chain reports failure once, at the end
}
accrued = accrued.RoundBank(2)
fmt.Printf("Principal: %v\n", principal.StringFixed())
fmt.Printf("Annual Interest Rate: %v\n", annualRate.String())
fmt.Printf("Days: %v\n", days)
fmt.Printf("Accrued Interest: %v\n", accrued.String())
total := principal.Add(accrued)
fmt.Printf("Total after %v days: %v\n", days, total.StringFixed())
}Arithmetic never panics and never returns an error. A failed operation returns a NaN carrying the reason, and NaN propagates, so a whole calculation is one expression checked once at the end:
total := principal.Mul(rate).Add(fee).RoundBank(2)
if err := total.ErrorDetails(); err != nil {
return err
}ErrorDetails returns a plain error and is the terminal check for a chain; IsNaN
is the boolean form. Nothing forces you to call either — unlike an ignored err there
is no compiler or linter backstop, so make the check part of your review habits.
Four things that surprise people:
IsZero,IsNegativeandIsPositiveall returnfalsefor NaN. A guard written asif !d.IsZero()does not catch a failed calculation. TestIsNaNfirst when the difference matters.MarshalJSONencodes NaN as the string"NaN". A consumer that accepts strings takes it without complaint.Valueis safer by construction — a numeric column rejects it.- Scale is capped at
MaxScale(19) and the cap is hard. An operation whose exact result needs more digits returns NaN rather than rounding silently. Round explicitly when a calculation is finished. SetDefaultScaleandSetNullValueare process-global. Set them once during initialisation; changing them while other goroutines calculate is a data race.
Scale is preserved rather than normalised, so 1.5 and 1.50 are distinct
representations that compare equal. Add/Sub take the larger operand scale, Mul
adds them, and Div uses the package default (SetDefaultScale, 6 by default) as a
floor — an operand with a larger scale keeps its own. QuoRem does not consult it
at all: the quotient is always an integer.
Because the cap is hard, dividing early costs precision and dividing at a scale near
MaxScale leaves no room to multiply afterwards:
// 5.0/365/100 truncated to 6 dp before the multiplications: 0.7% low
principal.Mul(annual.Div(d365).Div(d100)).MulInt(days) // 4.08
// one division, last: correct
principal.Mul(annual).MulInt(days).Div(dec128.FromInt64(36500)) // 4.109589 -> 4.11DivAtScale and SqrtAtScale take the scale per call and ignore the global entirely,
which is the right tool for a rate that needs more digits than your default:
rate := annual.DivAtScale(dec128.FromInt64(365), 12) // 0.013698630136By default a NULL scans to Zero, which loses the difference between "no amount
recorded" and "the amount is zero". SetNullValue makes it round-trip instead:
dec128.SetNullValue(dec128.Null()) // once, during initialisation
var amount dec128.Dec128
_ = rows.Scan(&amount)
amount.IsNull() // true for a NULL column
amount.Add(fee).IsNull() // true - NULL propagates the way SQL NULL does
v, _ := amount.Value() // nil, so it writes back as NULLA NULL-marked value is a NaN, so it propagates through arithmetic, but IsNull
distinguishes it from an overflow or a parse failure. MarshalJSON emits null for
it and UnmarshalJSON accepts null symmetrically. The default stays Zero, so
nothing changes unless you opt in.
FromString accepts both forms, so JSON, text and sql.Scanner input needs no extra
handling:
dec128.FromString("1.5e3") // 1500
dec128.FromString("-2.5E-2") // -0.025FromSafeString is regular form only. It skips all format checks by design, so there
is nothing for the exponent marker to be caught by, and adding a check would cost a
comparison per character on the fastest parsing path. Send input that may carry an
exponent through FromString, which detects the marker inside the validation it
already performs and so costs nothing extra.
Printing stays in the regular form unless the scientific one is asked for. String,
MarshalJSON, MarshalText and Value are unchanged:
d := dec128.FromString("12345")
d.String() // "12345"
d.StringSci() // "1.2345e+4"StringSci normalises to a single leading digit, always signs the exponent and drops
the trailing zeros of the mantissa; zero prints as 0e+0 and NaN as NaN. Use
StringSciToBuf with a [MaxSciStrLen]byte buffer to format without allocating.
Most Go decimal packages are built on math/big, which means a pointer, a heap
allocation per value and a variable memory footprint. That is the right trade for
arbitrary precision; it is the wrong one for a ledger that moves millions of fixed-scale
amounts. dec128 fixes the layout at 24 bytes, keeps arithmetic allocation-free, and
accepts a hard 19-digit scale cap in exchange. The numbers below are the result.
Measured on a MacBook Pro (2019), 2.6 GHz 6-core Intel Core i7, 16 GB RAM. Reproduce with https://github.com/jokruger/go-decimal-benchmark.
These figures predate the current default scale of 6 and are conservative for division
and square root, both of which do materially less work at a smaller scale — locally
Div measures ~15 ns and Sqrt ~78 ns at the default, against ~36 ns and ~330 ns at
scale 19. Re-run the suite before quoting the table.
parse (ns/op) string (ns/op) add (ns/op) mul (ns/op) div (ns/op)
dec128.Dec128 14.024 33.683 9.975 6.569 35.116
udecimal.Decimal 23.302 41.854 12.226 11.346 40.877
alpacadecimal.Decimal 89.528 78.884 206.393 60.364 451.828
shopspring.Decimal 152.263 169.300 218.909 65.241 428.002
- Scale: Number of digits after the decimal point. For example, 1.00 has scale of 2 and 1.0000 has scale of 4.
- Exponent: Same as scale, but in the context of low-level implementation details or Dec128 encoding.
- Canonical: The representation of a number with the minimum number of decimal places required to represent the number.
- Quantum*: The smallest step at a given scale. For example, scale 2 has quantum 0.01
- NaN: A value carrying a failure reason instead of a number. Produced by overflow, division by zero, a parse error and so on; inspect it with
IsNaNorErrorDetails.
This project is licensed under the MIT License. See the LICENSE file for details.
This project includes code derived from:
- A project licensed under the BSD 3-Clause License (Copyright © 2025 Quang).
- A project licensed under the MIT License (Copyright © 2019 Luke Champine).
See the LICENSE file for full license texts.