Skip to content

Money: integer cents, rounding, currencies

coreCommon atStripeSquareCoinbaseAdyenRead firstInput validationMath for interviewsPractise in the Sandbox →

Never store money in a binary float: 0.1 + 0.2 is not 0.3. Store an integer count of the currency’s minor unit (cents for USD, yen for JPY, fils for BHD), keep the currency next to it, and do every multiplication or division in integers. Round once, at a defined point, with a named rule (half-up or half-even). When splitting, allocate the leftover units explicitly so the parts always sum to the whole.

A float stores numbers in binary, the way 1/3 can only be written approximately in decimal (0.3333…). Most decimal fractions, like one tenth, have no exact binary form. So 0.10 is stored as a number a hair above one tenth, and every addition rounds again. The errors are tiny, but money code compares with ==, sums millions of values, and must reconcile to the cent.

The fix is old and simple: count whole minor units. $19.99 is the integer 1999. Integers add, subtract and compare exactly. The only operations that can produce fractions are multiplication by a rate (tax, fees, FX) and division (splits, averages), and those are exactly the places where you decide how to round.

Three more facts complete the picture:

  • Currencies have different minor units. ISO 4217 gives each currency an exponent: USD and EUR have 2 decimals, JPY and KRW have 0, BHD, KWD and JOD have 3. “Multiply by 100” is a bug waiting for the first yen payment.
  • An amount without a currency is meaningless. 1000 could be $10.00 or ¥1,000. Keep (amount, currency) together and refuse to add different currencies.
  • Rounding needs a rule. “Round half up” and “round half to even” give different answers on exact ties, and the spec decides which one is correct.

Type any two decimals to see what the computer actually stores. Then add a price many times: with 0.10 the float total is already wrong after 3 additions, while integer cents stay exact.

Why money is not a float
type numbers
+= 0.30000000000000004≠ 0.3
a is stored as exactly
0.1000000000000000055511151231257827021181583404541015625
b is stored as exactly
0.200000000000000011102230246251565404236316680908203125
the float sum is exactly
0.3000000000000000444089209850062616169452667236328125
float: total += 0.10
0.9999999999999999
integer cents: total += 10
100 cents = 1.00
The float total first drifts from the exact answer after 3 additions. Each step rounds to the nearest double, and the tiny errors pile up. Comparing total == 1.00 fails, and a report rounded to cents can be off by one when the drift crosses a half. Integer cents are exact up to 253 − 1 = 9,007,199,254,740,991 in a JavaScript number, and without limit in a Python int or a JS BigInt.
The problem says… What to do
amounts “in cents” / “minor units” / “integer amounts” Keep them as integers end to end. Don’t convert to dollars to compute.
a percentage, basis points (1 bp = 0.01%), a rate in parts per million Multiply first, divide last, in integers; round once with the stated rule.
“rounded to the nearest cent, halves rounded up” That’s a named mode. Implement it exactly, including negatives.
split a bill, pro-rate a refund, distribute fees Allocation: floor the shares, then hand out the leftover units.
several currencies A currency code on every amount, an exponent table, explicit conversion.
“costs are fractions of a cent; round only when reporting” Keep a finer integer unit (or an exact rational) internally and round at the edge.

Rates turn integers into fractions: 8.25% of 1999 cents is 1999 × 825 / 10000 = 164.9175 cents. You want a whole number, computed without ever leaving integers.

  1. Take the magnitude and remember the sign, so negative amounts (refunds) round symmetrically.
  2. Compare the remainder with half the divisor as 2 × r vs d. No floats, no 0.5.
  3. Above half: round up. Below: keep. Exactly half: the mode decides. Half-up (away from zero) rounds up; half-even rounds to the even neighbour.
div_round.py
from typing import Literal
Mode = Literal["half_up", "half_even", "floor"]
def div_round(n: int, d: int, mode: Mode) -> int:
"""n / d rounded to an integer with exact integer math. d > 0."""
if d <= 0:
raise ValueError("d must be positive")
if mode == "floor":
return n // d # Python's // already floors (toward -infinity)
q, r = divmod(abs(n), d) # 1. work on the magnitude, fix the sign at the end
twice = 2 * r # 2. compare r with d/2 without leaving integers
if twice > d or (twice == d and (mode == "half_up" or q % 2 == 1)):
q += 1 # 3. above half: up; exactly half: depends on mode
return q if n >= 0 else -q # "half_up" here means half away from zero
if __name__ == "__main__":
# 2.5 3.5 -2.5 2.4 2.6
tenths = [25, 35, -25, 24, 26]
assert [div_round(x, 10, "half_up") for x in tenths] == [3, 4, -3, 2, 3]
assert [div_round(x, 10, "half_even") for x in tenths] == [2, 4, -2, 2, 3]
assert [div_round(x, 10, "floor") for x in tenths] == [2, 3, -3, 2, 2]
# 8.25% tax on $19.99, in cents: 1999 * 825 / 10000 = 164.9175 -> 165
assert div_round(1999 * 825, 10_000, "half_even") == 165
# Python's round() is half-even too, but on binary floats: 2.675 is really 2.67499999...
assert round(2.5) == 2 and round(2.675, 2) == 2.67
print("ok")

The built-in rounders are traps on floats. Python’s round(2.675, 2) is 2.67 because the float is really 2.67499999…. JavaScript’s Math.round(-2.5) is -2, because it rounds halves toward +∞. On C#’s base-10 decimal, Math.Round(2.675m, 2) really is a tie, and it gives 2.68 under the default MidpointRounding.ToEven.

Ties are where the modes differ. Half-up is what people learn at school; half-even (banker’s rounding) sends ties to the even neighbour, so over many values it rounds up as often as down and the total doesn’t drift. Edit the values, or pick the cents preset to see float toFixed disagree with both.

Rounding modes side by side
edit the values
valuehalf away from 0half to evenhalf toward +∞floorceiltruncatefloat toFixed
0.51010101
1.52221212
2.53232323
3.54443434
4.55454545
-0.5-100-100-1
-1.5-2-2-1-2-1-1-2
-2.5-3-2-2-3-2-2-3
sum (exact 8.0)9
+1.0
8
no drift
12
+4.0
4
-4.0
12
+4.0
7
-1.0
an exact tie (…5 at the cut)
Columns differ only on ties, unless the mode is a directed one (floor, ceil, truncate). On the first preset, half-away drifts upward by 0.5 per positive tie, while half-to-even cancels out on average: that is why banks and IEEE 754 use it. Bold, coloured cells differ from half-away; a red toFixed matches neither tie rule, because 2.675 as a double is 2.67499999…, so the float was never a tie at all.
Mode 2.5 3.5 −2.5 Where you meet it
half away from zero (“half up”) 3 4 −3 school, most invoices, Python ROUND_HALF_UP, .NET AwayFromZero
half to even (banker’s) 2 4 −2 Python round() and decimal’s default context, .NET Math.Round, IEEE 754 default
half toward +∞ 3 4 −2 JavaScript Math.round
floor 2 3 −3 Python // and math.floor
truncate 2 3 −2 C#, Java and JS Math.trunc integer division

$100.00 split three ways is $33.333… each. Round each share and you hand out $99.99. A cent vanished, and the ledger won’t balance. The fix is the largest remainder method (also called Hamilton’s method), which Martin Fowler’s Money pattern popularized as allocate:

  1. Floor every share: total × weight ÷ sum_of_weights, in integers.
  2. The leftover is total − sum(floors), always fewer units than there are parties.
  3. Give the leftover out one minor unit at a time to the parties with the largest remainders. Break ties with a stated rule (here: the earliest party).
Split an amount without losing a cent
edit the split
partyweightexact shareround each sharelargest remainder
A133.333333…33.3333.34 (+0.01)
B133.333333…33.3333.33
C133.333333…33.3333.33
sum3100.0099.99100.00
received one leftover 0.01 USD
Rounding each share loses 0.01 USD: money that disappears. Largest remainder floors every share, then gives the 1 leftover minor unit to the parties whose exact share was cut the most (ties: first party). The parts always sum to the total.
allocate.py
def allocate(total: int, weights: list[int]) -> list[int]:
"""Split total minor units in proportion to weights; the parts always sum to total."""
if total < 0 or not weights or min(weights) < 0 or sum(weights) == 0:
raise ValueError("need total >= 0 and non-negative weights with a positive sum")
w_sum = sum(weights)
shares = [total * w // w_sum for w in weights] # 1. floor every share
leftover = total - sum(shares) # 2. 0 <= leftover < len(weights)
# 3. hand the leftover units out, one each, to the largest remainders (ties: earliest)
by_remainder = sorted(range(len(weights)), key=lambda i: (-(total * weights[i] % w_sum), i))
for i in by_remainder[:leftover]:
shares[i] += 1
return shares
if __name__ == "__main__":
assert allocate(100, [1, 1, 1]) == [34, 33, 33] # $1.00 three ways
assert allocate(1000, [70, 20, 10]) == [700, 200, 100] # exact: no leftover
assert allocate(5, [3, 7]) == [2, 3] # 1.5 and 3.5: tie goes to the first
assert allocate(1001, [1, 2, 3, 4]) == [100, 200, 300, 401]
naive = [round(100 * w / 3) for w in (1, 1, 1)] # per-share rounding: [33, 33, 33]
assert sum(naive) == 99 and sum(allocate(100, [1, 1, 1])) == 100 # a cent vanished
print("ok")

At the boundary, money arrives as text ("12.34") or as an already-integer amount. Parse text with a base-10 type or by hand, and reject amounts with more decimals than the currency allows instead of silently rounding them.

to_minor.py
from decimal import Decimal, InvalidOperation
# ISO 4217 minor-unit exponents: how many digits after the decimal point.
EXPONENT = {"USD": 2, "EUR": 2, "GBP": 2, "JPY": 0, "KRW": 0, "BHD": 3, "KWD": 3}
def to_minor(amount: str, currency: str) -> int:
"""'12.34' USD -> 1234. Parse the text as Decimal, never as float."""
exp = EXPONENT[currency] # 1. KeyError for unknown currencies
try:
d = Decimal(amount) # 2. base-10: "0.1" is exactly 0.1
except InvalidOperation:
raise ValueError(f"not a number: {amount!r}") from None
if not d.is_finite():
raise ValueError(f"not a number: {amount!r}")
scaled = d.scaleb(exp) # 3. shift the point by the exponent
if scaled != scaled.to_integral_value(): # 4. refuse to round silently
raise ValueError(f"{amount} has more than {exp} decimals for {currency}")
return int(scaled)
def format_minor(units: int, currency: str) -> str:
exp = EXPONENT[currency]
return f"{Decimal(units).scaleb(-exp):.{exp}f}" # 5. display only; never compute on this
if __name__ == "__main__":
assert to_minor("12.34", "USD") == 1234
assert to_minor("1500", "JPY") == 1500 # yen has no minor unit
assert to_minor("1.234", "BHD") == 1234 # dinar has three
assert format_minor(1234, "BHD") == "1.234" and format_minor(5, "USD") == "0.05"
assert format_minor(1500, "JPY") == "1500"
for bad, ccy in [("12.345", "USD"), ("0.5", "JPY"), ("12,34", "USD"), ("NaN", "USD")]:
try:
to_minor(bad, ccy)
raise AssertionError(bad)
except ValueError:
pass
print("ok")
Python TypeScript / JavaScript C#
Integer amounts int: arbitrary precision, never overflows number is exact only up to 2^53 − 1 (Number.MAX_SAFE_INTEGER); use bigint (123n) beyond that long (±9.2 × 10^18); checked { } to trap overflow
Base-10 type decimal.Decimal: default precision 28 digits, default rounding ROUND_HALF_EVEN none built in (a TC39 Decimal proposal is not shipped); use integers or a library decimal: 128-bit, base 10, 28-29 significant digits
Rounding Decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) write it in integer math (above) Math.Round(x, 2, MidpointRounding.AwayFromZero)
Display f"{d:.2f}" on a Decimal, or babel for locales Intl.NumberFormat("en-US", { style: "currency", currency: "JPY" }) knows each currency’s decimals x.ToString("C", culture)
Trap to avoid Decimal(0.1) copies the float error; write Decimal("0.1") 0.1 + 0.2 !== 0.3; (1.005).toFixed(2) is "1.00" double and float for money; decimal from a double literal
Time Space Notes
Add / compare integer amounts O(1) O(1) exact
Multiply by a rate, then round O(1) O(1) the product can overflow 64-bit, or exceed 2^53 in a JS number
Decimal / decimal arithmetic O(digits) small slower than integers (in Python, notably), still fine for business logic
Allocate among k parties O(k log k) O(k) the sort of remainders; O(k) with a selection

Overflow is the cost to watch. A ledger amount of 10^12 cents times a rate in parts per million is 10^18: it still fits in a 64-bit long (max about 9.2 × 10^18), but it is far beyond the 9 × 10^15 that a JavaScript number represents exactly. Use bigint in TypeScript, and say the bound out loud in C#.

  • Basis points and ppm. Rates arrive as integers to keep them exact: 290 bps is 2.90%, so a fee is amount × 290 / 10_000. A rate of 920,000 ppm means ×0.92. Multiply before dividing, or you lose precision.
  • Round at the line or at the total. Tax rounded per line item and tax rounded once on the invoice total can differ by a cent or more. The spec (or the tax authority) decides. Ask.
  • Sub-cent accumulation. Metered billing (per token, per API call) produces fractions of a cent per event. Accumulate in a finer integer unit, such as micro-cents, or as an exact fraction, and round only when a total is reported. Rounding every event to zero cents bills nothing.
  • Currency conversion. A rate is directional (USD→EUR is not exactly 1 / EUR→USD, because dealers quote a spread). Round once, into the target currency’s minor unit, and never convert back and forth.
  • Three-decimal and zero-decimal currencies. Payment providers add their own rules on top of ISO 4217. Stripe, for example, takes amounts in the smallest unit, treats JPY and KRW as zero-decimal, and asks that three-decimal amounts (BHD, KWD, JOD, OMR, TND) end in 0.
  • Rounding in the customer’s favour. Some fee rules always round down (floor) so the customer is never overcharged. That’s a directed mode, not a tie rule.
  • Floats anywhere in the pipeline, including JSON parsing of large amounts, parseFloat on input, or a * 0.029 buried in a fee calculation.
  • Dividing before multiplying. amount / 10_000 * bps in integer math throws away everything below 10,000. Do amount * bps / 10_000.
  • Rounding more than once. Rounding a subtotal, then rounding again after tax (“double rounding”) can differ from a single rounding of the exact value.
  • Assuming 2 decimals. cents = dollars * 100 breaks for JPY and BHD. Look up the exponent.
  • Losing the remainder on splits. Sum the parts in a test and assert that they equal the total.
  • Mixing currencies. usd + eur must be an error. A Money type with a currency field makes this impossible to write by accident.
  • Negative zero and signs. In JS, Math.round(-0.4) is -0, which prints as 0 but can compare oddly in strict tests. Normalize with x || 0 or x + 0 when the spec forbids -0.
  • Ask what unit amounts are in, which currencies appear, whether amounts can be negative, and how large they get.
  • Ask for the rounding rule and the point at which to round (per item, per total, per report). If the spec names one, repeat it back.
  • Say “I’ll keep everything in integer minor units and round once, here, with half-up away from zero.”
  • Test a tie (x.5), a negative tie, a zero amount, the largest amount, and a split that doesn’t divide evenly. Assert that splits sum to the total.
  • Likely follow-ups: add a second currency, change the rounding rule, pro-rate a partial refund, accumulate sub-cent costs, report totals per day without drift.
Check yourself
pick one
A fee is 2.9% of 1550 cents, rounded half up. 1550 × 290 / 10000 = 44.95. Which integer-only expression gives the right answer of 45 (for non-negative amounts)?
Check yourself
pick one
You split 1000 cents between three people by rounding each exact share (333.33…) to the nearest cent. What's the problem, and what's the fix?
Check yourself
pick one
You round these five amounts to whole dollars and add them up: 0.5, 1.5, 2.5, 3.5, 4.5 (exact sum 12.5). Which mode gives a total closest to 12.5?

Classic exercises, not gym questions:

  • Build a Money value type: an integer amount plus a currency code, with add, subtract, multiply(rate, rounding) and allocate(weights). Make adding different currencies a type error or an exception.
  • Price a shopping cart with tax applied per line and per invoice, and find a cart where the two differ.
  • A loan amortization schedule: monthly interest rounded to the cent, where the final payment absorbs all the rounding so the balance ends at exactly 0.
  • Split a restaurant bill where each person’s tip is proportional to what they ordered, and the parts must sum to the bill plus tip.
  • Compound a savings balance monthly for 30 years, once with Decimal/decimal/bigint and once with floats, and measure the difference.