Skip to content
ZYNQORALABS

Decimal arithmetic in financial systems: cents that never vanish

Binary floating point can't represent 0.10 exactly. How to represent money, choose rounding rules and split amounts without losing or creating cents.

By ZynqoraLabs · · 3 min read

ShareLinkedInXWhatsApp

In almost every language, 0.1 + 0.2 doesn't equal 0.3 but 0.30000000000000004. That isn't a bug: binary floating-point numbers (the IEEE 754 standard) can't represent most decimal fractions exactly. On a chart the difference is irrelevant. On a statement, a reconciliation or a limit check, it becomes cents that appear or vanish with no explanation.

Rule zero: money is not a float

There are two safe representations for monetary values:

  1. Integers in the currency's minor unit. $12.34 becomes 1234 cents. Sums and comparisons are exact, and every language runs them fast.
  2. Fixed-precision decimal types. Decimal in Python, BigDecimal in Java, rust_decimal in Rust, numeric in PostgreSQL. They represent decimal fractions exactly and let you control rounding.

Integers work well for final amounts in a single currency. Decimals are needed when there are rates, prices with more digits than the currency, conversions and interest calculations.

The minor unit depends on the currency

Not every currency has two decimal places. ISO 4217 defines each currency's number of digits: the Brazilian real and the US dollar have 2, the Japanese yen has 0, and the Bahraini dinar has 3. In crypto, ERC-20 tokens commonly use 18.

So a monetary value should never travel without its currency. A Money type holding amount and currency prevents adding reais to dollars and tells you how many digits to round to.

Rounding is a business rule

Whenever a calculation yields more digits than the currency allows, you must round, and the rule you pick changes the result:

ValueHalf upHalf even (banker's)
2.3452.352.34
2.3552.362.36
2.3652.372.36

Half-even rounding avoids a systematic upward bias when many values are rounded, and it's the default in several libraries. But the correct rule is the one the contract, the regulator or the counterparty requires. It must be explicit in code and covered by tests, never inherited from an implicit language default.

money.py
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
 
CURRENCY_EXPONENT = {"BRL": 2, "USD": 2, "JPY": 0, "BHD": 3}
 
 
def quantize(amount: Decimal, currency: str, rounding=ROUND_HALF_EVEN) -> Decimal:
    exp = Decimal(1).scaleb(-CURRENCY_EXPONENT[currency])  # 0.01 for USD
    return amount.quantize(exp, rounding=rounding)
 
 
fee = Decimal("1234.56") * Decimal("0.0175")   # Decimal("21.604800"), exact
quantize(fee, "USD")                           # Decimal('21.60')
quantize(Decimal("2.345"), "USD")              # Decimal('2.34')  (half-even)
quantize(Decimal("2.345"), "USD", ROUND_HALF_UP)  # Decimal('2.35')

Notice the values are created from strings (Decimal("1234.56")). Building a Decimal from a float inherits exactly the binary error you were trying to avoid.

Splitting without losing cents

Splitting $100.00 into three equal parts gives 33.333... Rounding each part to 33.33 sums to $99.99, and a cent disappears. At scale, those cents become reconciliation breaks.

The fix is allocation: compute the parts in the minor unit and distribute the remainder one cent at a time, deterministically.

allocate.py
def allocate(total_minor: int, weights: list[int]) -> list[int]:
    """Split an integer amount by weights; the parts always sum to the total."""
    weight_sum = sum(weights)
    shares = [total_minor * w // weight_sum for w in weights]
    remainders = [total_minor * w % weight_sum for w in weights]
    leftover = total_minor - sum(shares)
    # Give the leftover cents to the largest remainders (ties: earliest first).
    for i in sorted(range(len(weights)), key=lambda i: -remainders[i])[:leftover]:
        shares[i] += 1
    return shares
 
 
allocate(10_000, [1, 1, 1])   # [3334, 3333, 3333] -> sums to 10000
allocate(10_000, [70, 30])    # [7000, 3000]

The same technique applies to splitting fees across participants, distributing interest and dividing an order across several accounts.

Boundaries: database, APIs and JSON

Precision tends to be lost at the edges of the system:

  • Database: use numeric(p, s) or integers. float or double precision columns bring the problem back.
  • JSON: JSON numbers are usually parsed as double in JavaScript, which only represents integers exactly up to 2^53. For large or decimal values, send the amount as a string with the currency in its own field.
  • Spreadsheets and reports: CSV exports should format with the currency's precision, not the number's internal representation.
POST /transfers
{ "amount": "1234.56", "currency": "BRL" }

Tests worth writing

  • Values on the rounding boundary (x.xx5) for every rule in use.
  • Property: allocated parts always sum to the total, for any total and any weights.
  • Round-trip currency conversions never produce values with more digits than the currency allows.
  • Serialization and deserialization preserve the exact value.

Summary

Money requires integers in the minor unit or decimal types, always paired with the currency. Rounding is an explicit business rule, splitting amounts is done by allocation so no cent is lost, and precision must be preserved at the edges: database, APIs and exports. These are small decisions that prevent the discrepancies hardest to explain in an audit.