Deep dive
How it works
In: decimal string or integer amount + currency, optional rate · Out: exact integer minor units, rounded once · State: none (currency table with exponents) · Cost: O(1); i64 add 1 cycle, i128 divide tens of ns
- Representation:
(minor: i64, currency); the currency carries its exponente. 12.34 USD =(1234, USD); 1,000 JPY =(1000, JPY); 1.234 KWD =(1234, KWD). - Add, subtract, compare: only within the same currency, with checked arithmetic (overflow is a bug, not a wrap).
- Multiply by a rational (rate, percentage, weight): widen to
i128, computeminor × num, then divide bydenwith one rounding step. Never round intermediate results. - Convert currencies:
minor_to = minor_from × rate × 10^e_to / 10^e_from, with the rate itself as an integer mantissa and scale (e.g. 1.08345 = 108345 × 10⁻⁵). - Split / allocate: a total divided into parts must sum back to the total. Truncate each share, then hand out the leftover minor units one by one to the largest remainders.
- I/O: parse decimal strings directly into integers (never via float); reject more fractional digits than the currency has instead of rounding silently. In JSON send integers within 2⁵³ or strings.
Code
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Currency { pub code: [u8; 3], pub exp: u32 }
pub const USD: Currency = Currency { code: *b"USD", exp: 2 };
pub const JPY: Currency = Currency { code: *b"JPY", exp: 0 };
pub const KWD: Currency = Currency { code: *b"KWD", exp: 3 };
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Money { pub minor: i64, pub cur: Currency }
#[derive(Clone, Copy, Debug)]
pub enum Rounding { HalfEven, HalfUp, Down, Floor, Ceiling } // HalfUp = half away from zero; Down = toward zero
/// n / d rounded with `mode`; requires d > 0.
pub fn div_round(n: i128, d: i128, mode: Rounding) -> i128 {
let (q, r) = (n / d, n % d); // Rust truncates toward zero; r has the sign of n
if r == 0 { return q; }
let away = if n < 0 { -1 } else { 1 };
let twice = 2 * r.abs();
let bump = match mode {
Rounding::Down => false,
Rounding::Floor => n < 0,
Rounding::Ceiling => n > 0,
Rounding::HalfUp => twice >= d,
Rounding::HalfEven => twice > d || (twice == d && q % 2 != 0),
};
if bump { q + away } else { q }
}
impl Money {
pub fn checked_add(self, o: Money) -> Option<Money> {
if self.cur != o.cur { return None; }
Some(Money { minor: self.minor.checked_add(o.minor)?, cur: self.cur })
}
/// Multiply by num/den (e.g. a fee of 290/10_000 = 2.9 %), rounding once.
pub fn mul_ratio(self, num: i64, den: i64, mode: Rounding) -> Option<Money> {
if den <= 0 { return None; }
let v = div_round(self.minor as i128 * num as i128, den as i128, mode);
Some(Money { minor: i64::try_from(v).ok()?, cur: self.cur })
}
/// Convert at rate = mant × 10^-scale units of `to` per unit of self.cur.
pub fn convert(self, mant: i128, scale: u32, to: Currency, mode: Rounding) -> Option<Money> {
let num = (self.minor as i128).checked_mul(mant)?.checked_mul(10i128.checked_pow(to.exp)?)?;
let den = 10i128.checked_pow(scale + self.cur.exp)?;
Some(Money { minor: i64::try_from(div_round(num, den, mode)).ok()?, cur: to })
}
}
/// Split `total` by integer weights so the parts sum exactly to `total` (largest remainder).
pub fn allocate(total: i64, weights: &[u64]) -> Vec<i64> {
let sum: i128 = weights.iter().map(|&w| w as i128).sum();
assert!(sum > 0, "weights must not all be zero");
let mut parts = Vec::with_capacity(weights.len());
let mut rems = Vec::with_capacity(weights.len());
for (i, &w) in weights.iter().enumerate() {
let x = total as i128 * w as i128;
parts.push((x / sum) as i64); // truncate toward zero
rems.push(((x % sum).abs(), i));
}
let mut left = total - parts.iter().sum::<i64>(); // |left| < number of parts
rems.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); // largest remainder, then index: deterministic
let step = left.signum();
for &(_, i) in &rems {
if left == 0 { break; }
parts[i] += step;
left -= step;
}
parts
}
/// "-12.34" -> -1234 minor units. Rejects excess decimals rather than rounding.
pub fn parse(s: &str, cur: Currency) -> Option<Money> {
let (neg, s) = match s.strip_prefix('-') { Some(r) => (true, r), None => (false, s) };
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
if int.is_empty() || frac.len() > cur.exp as usize { return None; }
let mut v: i64 = 0;
for c in int.bytes().chain(frac.bytes()) {
if !c.is_ascii_digit() { return None; }
v = v.checked_mul(10)?.checked_add((c - b'0') as i64)?;
}
for _ in frac.len()..cur.exp as usize { v = v.checked_mul(10)?; }
Some(Money { minor: if neg { -v } else { v }, cur })
}
pub fn format(m: Money) -> String {
let a = m.minor.unsigned_abs(); // safe for i64::MIN
let sign = if m.minor < 0 { "-" } else { "" };
if m.cur.exp == 0 { return format!("{sign}{a}"); }
let p = 10u64.pow(m.cur.exp);
format!("{sign}{}.{:0w$}", a / p, a % p, w = m.cur.exp as usize)
}
Examples: allocate(100, &[1, 1, 1]) → [34, 33, 33]; div_round(25, 10, HalfEven) → 2, div_round(35, 10, HalfEven) → 4, div_round(-25, 10, HalfUp) → −3.
Complexity and constants
i64add/compare: 1 cycle.i128multiply: a few instructions on x86-64;i128division calls a runtime routine, order of tens of ns. Still far cheaper than arbitrary-precision decimals, which allocate.- Storage: 8 bytes + currency (2-byte index or 3-byte code). SQL:
BIGINTminor units +CHAR(3)currency, orNUMERIC(p, s).
Efficiency
Cost: arithmetic is not the bound; storage width and the rare division are. An i64 add or compare is one cycle; an i128 multiply is a few instructions and an i128 division is a runtime call, order of tens of ns. A value is 8 bytes plus a 2-byte currency index.
Levers
- Stay in
i64— avoids thei128division — usei128only for the product inmul_ratioandconvert, skip it whenchecked_mulsucceeds — when most amounts are small. - Exact sums parallelize — vectorized or multi-threaded
SUMin any order — integer addition is associative, floats are not — bulk aggregation over aBIGINTcolumn. - Narrow storage and
u64parsing with a table of powers of ten — fewer bytes, no float detour — every I/O path. - Round once on the exact rational — one division instead of one per step — splits, taxes, FX.
Naive → standard → state of the art
- Naive:
f64amounts · cheap but drifts (0.1 + 0.2 ≠ 0.3) - Standard:
i64minor units + currency,i128intermediates · exact, 8 bytes, 1 cycle per add - Alternatives when scale is unknown or sub-minor precision is needed: fixed-size decimal (
rust_decimal, 128 bit) before arbitrary-precisionbigdecimal, which allocates
Implementation notes
- Exponent source: take it from ISO 4217, but payment processors sometimes deviate (some treat a currency as zero-decimal where ISO says 2). Store the exponent per (currency, counterparty) when talking to external APIs.
- Where to round: rounding per line item vs on the invoice total gives different totals; tax rules often prescribe one. Make the rounding point and mode part of the business rule, not an accident of code.
- Legal modes: some rules mandate half-up rather than half-even (e.g. the EU euro-conversion regulation 1103/97 rounds exact halves up). Pass the mode explicitly everywhere.
- Database:
BIGINTminor units is fastest and unambiguous. PostgreSQLNUMERICis exact but slower; avoid PostgreSQL'smoneytype, whose input/output depends on thelc_monetarylocale setting. - Language decimals: Java
BigDecimal(pass aRoundingMode;dividewithout one throws on non-terminating results), C#decimal(96-bit integer with scale 0-28), Pythondecimal(default 28 significant digits,ROUND_HALF_EVEN). Use them at the edges if needed; keep the core in integers. - FX rates: keep 6-10 significant digits as integer mantissa + scale; never store a converted amount without the rate used.
Pitfalls
(amount * 1.029) as i64: float multiply plus truncation; off by one cent on a fraction of inputs, and always toward zero.- Rounding twice (convert, round, apply fee, round again) instead of once on the exact rational.
- Splitting 100 into thirds as 33 + 33 + 33: a cent vanishes. Use
allocate. - Hard-coding 2 decimals: JPY amounts become 100× too large; KWD loses precision.
- JavaScript clients parsing large integer JSON amounts into
Number: precision loss beyond 2⁵³. - Comparing amounts in different currencies, or summing them into one "total".
abs()oni64::MINoverflows; useunsigned_abs().
Variants and alternatives
BigDecimal, NUMERIC)when inputs have unknown scale (e.g. accounting imports); slower, allocates.In production
Payments: Stripe's API takes amounts as integers in the smallest currency unit; most ledgers and payment processors store BIGINT minor units; TigerBeetle uses u128 amounts. Blockchain: Bitcoin Core CAmount (source). Cloud and API design: google.type.Money (proto).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| rust_decimal | Rust | paupino/rust-decimal | cargo add rust_decimal | — | — | — | — | fixed-size decimal (128 bit); no currency type |
| rusty-money | Rust | varunsrin/rusty_money | cargo add rusty-money | — | — | — | — | Money and Currency types, ISO 4217 data, formatting |
| iso_currency | Rust | zbrox/iso_currency | cargo add iso_currency | — | — | — | — | ISO 4217 codes and exponents as an enum |
| bigdecimal | Rust | akubera/bigdecimal-rs | cargo add bigdecimal | — | — | — | — | arbitrary precision; for unknown scale |
| Joda-Money | Java | JodaOrg/joda-money | Maven org.joda:joda-money | — | — | — | — | amounts with currency and rounding modes |
Sources
- ISO 4217, Codes for the representation of currencies, with its current list of minor units.
- IEEE 754-2019, IEEE Standard for Floating-Point Arithmetic. doi
- Martin Fowler, Patterns of Enterprise Application Architecture, Addison-Wesley, 2002 (Money pattern). pattern
- Council Regulation (EC) No 1103/97, Article 5 (rounding of converted amounts). EUR-Lex