Exact monetary arithmetic for Go, in integer minor units.
An amount is a count of cents, pence or yen plus an ISO-4217 currency code.
There is no float anywhere in this package and there never will be: binary
floating point cannot represent 0.10, and a system that rounds badly issues
refunds that do not reverse the charge they refund.
price, _ := money.Parse("19.99", "EUR") // 1999 minor units, exactly
line := price.Mul(3) // 59.97 EUR
tax, _ := line.ApplyRate(1900, money.HalfUp) // 19% VAT -> 11.39 EUR
total, _ := money.Add(line, tax) // 71.36 EURNo dependencies outside the standard library.
- Install
- Amounts
- Arithmetic
- Rounding
- Rates and tax
- Allocation
- Parsing
- JSON and text
- Currency conversion
- Currencies and formatting
- Errors
- Storing amounts
- What this package deliberately does not do
- Contributing
go get github.com/r-52/moneyRequires Go 1.24 or later.
type Amount struct {
Minor int64
Currency Currency
}Minor is whole minor units — 1999 is €19.99, and 1500 is ¥1500, because the
yen has no minor unit. Currency is the ISO-4217 alphabetic code.
price := money.New(1999, "EUR")
free := money.Zero("EUR")
price.IsZero() // false
price.IsNegative() // falseAmount is a comparable value type, so == works and amounts can be map keys.
The zero Amount has an empty currency; it is valid only as an accumulator
seed, and combining it with a real amount adopts that amount's currency, so a
var total money.Amount can start a sum.
That last rule is convenient and slightly dangerous, since a blank currency is
compatible with every other one. So the places where an amount enters from
outside — parsing, decoding,
conversion — all insist on a real code, and
Currency.Valid is exported for boundaries of your own.
Anything that combines two amounts checks the currency first and returns an error rather than producing a nonsense number.
sum, err := money.Add(a, b)
diff, err := money.Sub(a, b)
total, err := money.Sum(lines...) // errors on the first mismatch
total := money.MustSum(lines...) // panics instead, for call sites
// that already established one currencyScaling by a whole number is exact and cannot need rounding, so it is a method with no error to check:
lineTotal := unitPrice.Mul(quantity)
credit := charge.Neg()Comparison and clamping:
c, err := money.Cmp(a, b) // -1, 0, +1
lo, err := money.Min(a, b)
hi, err := money.Max(a, b)
net := subtotal.ClampNonNegative() // a discount must not turn a charge into a payoutDivision is the only place money can go wrong, so every division names its rounding mode at the call site. Tax authorities genuinely disagree about this, and burying a default in the arithmetic makes the disagreement invisible.
| Mode | Behaviour |
|---|---|
money.HalfUp |
a .5 remainder rounds away from zero — the commercial default, and the assumption behind most published tax tables |
money.HalfEven |
a .5 remainder rounds to the nearest even unit, so large batches do not drift upward |
money.Down |
truncates toward zero |
MulDiv is the single primitive underneath everything else:
part, err := amount.MulDiv(num, den, money.HalfUp) // amount * num / denIt computes through a 128-bit intermediate, so a large amount times a large
numerator does not silently overflow on the way to a quotient that fits
comfortably in an int64. A quotient that genuinely does not fit returns an
error rather than a wrong price.
Rates are basis points — hundredths of a percent, so 1900 is 19% and 825 is
8.25%. Basis points keep rates exact integers, which neither floats nor decimal
percentages manage for a value like 8.25%.
tax, err := net.ApplyRate(1900, money.HalfUp) // 19% of a net price
tax, err := gross.ExtractRate(1900, money.HalfUp) // the 19% already inside a gross priceExtractRate computes gross * bp / (10000 + bp), which is not the same as
applying the rate to the gross figure. Selling at a tax-inclusive price and
deriving the tax from it is the norm across the EU, and getting it wrong is a
classic off-by-a-few-cents bug:
gross := money.New(11_900, "EUR") // 119.00, 19% VAT included
tax, _ := gross.ExtractRate(1900, money.HalfUp) // 19.00 EUR ✓
wrong, _ := gross.ApplyRate(1900, money.HalfUp) // 22.61 EUR ✗Allocate splits an amount across buckets in proportion to weights so that the
parts sum to exactly the whole — no unit created, none lost.
parts, err := money.Allocate(total, []int64{6000, 3000, 1000})
parts, err := money.AllocateBy(total, subtotals) // same, keyed on amountsIt uses largest-remainder apportionment: each bucket takes its floored proportional share, then the leftover units go one apiece to the buckets with the largest fractional parts, ties broken by position. The result is deterministic — the same inputs split the same way on every run and every machine, which is what makes golden files and stored explanations meaningful.
// A 10.00 discount over three lines.
parts, _ := money.AllocateBy(money.New(1000, "EUR"), []money.Amount{
money.New(1999, "EUR"),
money.New(999, "EUR"),
money.New(499, "EUR"),
})
// 5.71 EUR, 2.86 EUR, 1.43 EUR — which adds to exactly 10.00 EURThis is what makes an order-level discount reversible. Refunding one line returns exactly the units that line was charged, because the split was recorded rather than recomputed from a percentage that no longer divides the same way.
Two edge cases are pinned down rather than left to chance: a negative total (a refund) allocates by magnitude and every part points the same way, and weights that are all zero fall back to an even split, since proportion is undefined but conservation still has to hold.
The invariant is property-tested over hundreds of thousands of random splits: the parts always sum to the total, and no bucket ever strays a full minor unit from its exact proportional share.
Amounts arrive as strings all the time — a price column in a CSV, a JSON field
from a partner, a form value, a command-line flag. Without a parser here, that
string meets strconv.ParseFloat, which is the one thing this package exists
to prevent.
price, err := money.Parse("19.99", "EUR") // 1999
price := money.MustParse("19.99", "EUR") // for fixtures and constants
price, err := money.ParseAmount("19.99 EUR") // currency in the stringPrecision is exact or it is an error. The number of decimal places accepted is the currency's exponent:
money.Parse("19.99", "EUR") // 1999
money.Parse("19.9", "EUR") // 1990 — fewer decimals just scale up
money.Parse("19.990", "EUR") // 1999 — the extra digit is a zero, nothing is lost
money.Parse("19.995", "EUR") // error: EUR cannot represent that
money.Parse("1500.5", "JPY") // error: the yen has no minor unit at allRounding a price is a decision, so it is made explicitly or not at all:
money.ParseRound("19.995", "EUR", money.HalfUp) // 2000
money.ParseRound("19.995", "EUR", money.HalfEven) // 2000, tie to even
money.ParseRound("19.999", "EUR", money.Down) // 1999The grammar is deliberately narrow: an optional sign, digits, an optional
point, digits. No thousands separators, no spaces, no currency symbol, no
exponent notation. "1,234.56" and "1 234,56" are rejected rather than
guessed at, because the guess is how €1.234 becomes €1234 — call
strings.TrimSpace or strip separators yourself, where the choice is visible.
A blank or malformed currency is refused too, for the reason in
Amounts.
Parse is the inverse of String, and the round trip is property-tested and
fuzzed: anything that parses renders to something that parses back to it.
Amount implements json.Marshaler, json.Unmarshaler,
encoding.TextMarshaler and encoding.TextUnmarshaler.
JSON is the exact form — minor units and the code, so no consumer has to know the exponent to read it back, and none can mistake it for a float:
{"minor": 7136, "currency": "EUR"}Text is the human form, Amount.String and its inverse — what a config file or
a flag.Value should carry, and what encoding/json uses for a map key:
19.99 EUR
Both refuse to carry a value whose currency is blank or malformed, in either
direction. The one exception is the zero Amount — zero in no currency, the
seed a sum starts from — which travels as itself: "0" in text,
{"minor":0,"currency":""} in JSON.
b, _ := json.Marshal(money.New(7136, "EUR")) // {"minor":7136,"currency":"EUR"}
var a money.Amount
json.Unmarshal([]byte(`{"minor":1999,"currency":"eur"}`), &a) // error: bad code
json.Unmarshal([]byte(`{"minor":1999}`), &a) // error: no codeUnmarshalJSON also accepts the text form in a JSON string. That is not
leniency for its own sake: encoding/json quotes map keys and routes them to
UnmarshalJSON rather than UnmarshalText, so without it an Amount-keyed
map would marshal and then fail to unmarshal.
A Rate is an exact ratio between two currencies, held as integers so a rate
like 1.08734 is not approximated on the way in.
usd := money.Rate{From: "EUR", To: "USD", Num: 108_734, Den: 100_000}
usd, err := money.NewRate("EUR", "USD", 108_734, 100_000) // same, checked
converted, err := usd.Convert(money.New(10_000, "EUR"), money.HalfUp) // 108.73 USD
_, err = usd.Convert(money.New(10_000, "GBP"), money.HalfUp) // errorThis package converts money; it does not source rates. Where the number came from, when it was quoted, what spread it includes and how long it stays valid are facts a rate needs and none of them are arithmetic, so they live in your system next to your rate table.
What Rate buys you over reaching for MulDiv directly is that the currency
change is checked rather than assumed. MulDiv preserves the currency, so
converting with it means assembling Amount{…, "USD"} by hand — and an
Amount whose Currency field was set by hand is exactly what reconciles
wrong three months later.
ConvertAll converts a set of amounts so that they still add up:
parts, err := rate.ConvertAll(lines, money.HalfUp)Converting each line on its own does not do that — every line rounds
independently and the errors accumulate, so a converted invoice can miss its
converted total by several units. ConvertAll converts the total once and
apportions it back with AllocateBy, which is the same trick that makes a
discount reversible.
Conversion is lossy in the way any division is. Rate.Invert gives you the
exact reciprocal for undoing an internal conversion, but converting and
converting back is not guaranteed to return the original amount, and a real
market pair is not a reciprocal in either case. Convert once, at a rate you
recorded, and store what came out.
Currency.Exponent gives the number of minor units per major unit — 2 by
default, 0 for JPY, KRW, ISK and the other zero-decimal currencies, 3 for the
Gulf dinars. Unknown codes get the 2-decimal default. Currency.Valid checks
the shape of a code: three letters, upper case.
Amount.String places the decimal point accordingly:
money.New(150_000, "EUR") // "1500.00 EUR"
money.New(150_000, "JPY") // "150000 JPY"
money.New(150_000, "KWD") // "150.000 KWD"That format is for logs, tests, config and round trips. End-user display needs
locale-aware formatting — thousands separators, symbol placement, the comma
that German uses as a decimal point — and belongs at the edge of your system,
with golang.org/x/text/currency or the equivalent in your frontend.
Amount.Units hands out the pieces so that edge does not have to reimplement
decimal placement:
major, minor := money.New(-1999, "EUR").Units() // 19, 99
// both are magnitudes; take the sign from IsNegative, because -0.99
// has no negative major unit to carry it| Error | When |
|---|---|
MismatchError |
two different currencies met in one operation; it carries both codes |
ParseError |
a string could not be read as an amount; it carries the input and the reason |
ErrInvalidCurrency |
a currency code was missing or not three upper-case letters |
ErrRange |
a parsed value will not fit in int64 minor units |
ErrDivideByZero |
a zero denominator reached MulDiv, or a Rate had one |
ErrNegativeRate |
a conversion ratio had a negative side |
ErrNegativeWeight |
an allocation weight was below zero |
ErrNoBuckets |
an allocation had nowhere to put the money |
ParseError wraps ErrInvalidCurrency or ErrRange where one applies, so
errors.Is sorts a bad currency or an out-of-range value from a plain syntax
error:
var mismatch money.MismatchError
if errors.As(err, &mismatch) {
log.Printf("cannot mix %s with %s", mismatch.A, mismatch.B)
}
if errors.Is(err, money.ErrInvalidCurrency) {
// the code was blank or malformed, wherever it came from
}Store the two fields as two columns — a BIGINT and a CHAR(3) — never as a
FLOAT, and preferably not as a NUMERIC you then read back through a float.
price_minor BIGINT NOT NULL,
price_currency CHAR(3) NOT NULLAmount carries no db struct tags, deliberately: a library has no business
naming your columns, and a fixed pair of names collides the moment one row
holds two amounts. Scan into your own row struct and build the amount from it:
type priceRow struct {
Minor int64 `db:"price_minor"`
Currency string `db:"price_currency"`
}
price := money.New(r.Minor, money.Currency(r.Currency))Keeping the currency next to every stored amount, rather than assuming one currency per table or per tenant, is what lets the mismatch check do its job at the boundary instead of failing silently three joins later.
- Source exchange rates.
Rateapplies a ratio you supply; it will not fetch one, cache one, or decide when one went stale. Those are facts about your business, not about arithmetic. - Locale-aware display. Thousands separators, symbol placement and
positioning rules need CLDR data, which would dwarf this package. See
above —
Amount.Unitsis the hook. - A currency registry. Only the exponent matters for arithmetic, so only
the exponents are here. Names, symbols and minor-unit names are
presentation, and
Currencyis a plain string type you can key your own table on. - Locale-shaped parsing.
Parsereads19.99, not19,99or1 234,56. Deciding which convention a given input follows is something only your system knows, and guessing wrong is silent.
See CONTRIBUTING.md. Bug reports with a failing test case are the most useful thing there is.
MIT — see LICENSE.