Home › Financial infrastructure › Ledgers & money
algorithm low

Money representation

Problem Software that stores, adds, splits and converts amounts of money must get totals exactly right to the last cent, but ordinary decimal numbers on computers drift and round in surprising ways.

Store money as integer minor units with an explicit currency exponent, compute in wider integers, and round once with a named rounding mode.

O(1) per operation; i64 add/compare 1 cycle, i128 multiply/divide tens of cycles
minor-unitsfixed-pointroundingiso-4217decimalcurrency
Overview

Binary floats cannot represent 0.10 exactly, so sums drift and equality checks fail. The fix is the same wherever amounts are summed: payments, accounting, billing APIs, blockchains. Instead, store an amount as an integer count of the currency's minor unit (cents for USD, yen for JPY, fils for KWD) together with the currency code. Addition and comparison become exact integer operations; only multiplication by a non-integer (FX rate, interest, tax, split) produces fractions, and those are rounded once, at a defined point, with an explicitly chosen rounding mode.

Key facts

Use when

  • Any balance, price, fee, invoice or ledger amount in a database, API or in-memory model: payments, accounting, cloud billing, e-commerce, advertising spend.
  • Crypto: same idea with larger exponents (BTC 8, ETH 18), which forces i128/u128 or 256-bit integers for wei. Bitcoin Core keeps amounts as int64_t satoshis (CAmount, COIN = 100,000,000).
  • Cross-service APIs: Google's google.type.Money is a currency code plus integer units and nanos (10^-9).

Avoid when

  • Statistical or risk computations (VaR, Greeks, pricing models): floats are fine there; convert to integer money only when a result becomes a booked amount.
  • Prices that need more precision than the currency's minor unit (FX rates, sub-penny quotes, unit prices like 3.459/l): store them as scaled integers with their own scale, not as money.

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

  1. Representation: (minor: i64, currency); the currency carries its exponent e. 12.34 USD = (1234, USD); 1,000 JPY = (1000, JPY); 1.234 KWD = (1234, KWD).
  2. Add, subtract, compare: only within the same currency, with checked arithmetic (overflow is a bug, not a wrap).
  3. Multiply by a rational (rate, percentage, weight): widen to i128, compute minor × num, then divide by den with one rounding step. Never round intermediate results.
  4. 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⁻⁵).
  5. 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.
  6. 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

  • i64 add/compare: 1 cycle. i128 multiply: a few instructions on x86-64; i128 division 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: BIGINT minor units + CHAR(3) currency, or NUMERIC(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 the i128 division — use i128 only for the product in mul_ratio and convert, skip it when checked_mul succeeds — when most amounts are small.
  • Exact sums parallelize — vectorized or multi-threaded SUM in any order — integer addition is associative, floats are not — bulk aggregation over a BIGINT column.
  • Narrow storage and u64 parsing 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: f64 amounts · cheap but drifts (0.1 + 0.2 ≠ 0.3)
  • Standard: i64 minor units + currency, i128 intermediates · 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-precision bigdecimal, 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: BIGINT minor units is fastest and unambiguous. PostgreSQL NUMERIC is exact but slower; avoid PostgreSQL's money type, whose input/output depends on the lc_monetary locale setting.
  • Language decimals: Java BigDecimal (pass a RoundingMode; divide without one throws on non-terminating results), C# decimal (96-bit integer with scale 0-28), Python decimal (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() on i64::MIN overflows; use unsigned_abs().

Variants and alternatives

Arbitrary-precision decimal (BigDecimal, NUMERIC)when inputs have unknown scale (e.g. accounting imports); slower, allocates.
Fixed scale beyond minor units (e.g. 10⁻⁶ of a currency)for accruals (interest per day), billing per API call, or prices; round to minor units only when booking.
IEEE 754 decimal64/decimal128decimal floating point; exact for decimal inputs but rare in practice outside some databases and hardware.

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

namelangrepoinstallverdictlicencehealthas_ofnotes
rust_decimalRustpaupino/rust-decimalcargo add rust_decimal————fixed-size decimal (128 bit); no currency type
rusty-moneyRustvarunsrin/rusty_moneycargo add rusty-money————Money and Currency types, ISO 4217 data, formatting
iso_currencyRustzbrox/iso_currencycargo add iso_currency————ISO 4217 codes and exponents as an enum
bigdecimalRustakubera/bigdecimal-rscargo add bigdecimal————arbitrary precision; for unknown scale
Joda-MoneyJavaJodaOrg/joda-moneyMaven 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

Builds on

largest remainder rounding · plannedSplitting an amount into parts that sum exactly (largest remainder / Hamilton) for allocations, splits and prorations

Used by

call auction clearing · plannedUniform-price call auction: aggregate supply/demand curves and find the clearing price and volume
Double-entry ledgerAny system that keeps balances for users, accounts or resources must say where every unit went and prove that none was created or lost, even after bugs and corrections.Ranked-slot auction (GSP and VCG)A marketplace has k ordered positions of unequal value and many bidders who also differ in quality. It must pick who gets which position and what each pays, without a human setting prices, under bidding that may be strategic.
margin liquidation · plannedMark-to-market margin calls and forced liquidation of under-margined positions, incl. insurance fund and auto-deleveraging
Matching engineAn exchange must decide which buy and sell orders trade, in a fair and reproducible order, and after a crash end up with exactly the same result.
portfolio margining span · plannedScenario-based portfolio margin (SPAN-style risk arrays, offsets across correlated products)
Pre-trade risk checksA firm that sends orders automatically must stop mistaken or runaway orders before they leave, in a time too short to slow trading, to protect itself and the market.ReconciliationOrganisations that keep their own records of transfers, usage or stock must compare them with the other party's records and explain every difference, so lost, duplicated or wrong items are found and owned.
tariff rating · plannedRate usage events against price plans: tiers, time bands, bundles and rounding rules
tax lot accounting · plannedPer-lot cost basis (FIFO, specific ID), wash-sale rules and tax-loss harvesting

Systems (101):

Alternatives and related