A support ticket landed on my desk a few years ago with the subject line "invoice off by one cent." A merchant in the Netherlands had split a €100.00 order across three line items and our system had produced €33.33, €33.33, and €33.33. That sums to €99.99. One cent had quietly evaporated. The merchant noticed because their accountant reconciled against the bank settlement and the numbers refused to tie out. A single cent, on a hundred-euro order, turned into a two-week investigation.
That is the thing about money rounding. It is never about the one cent. It is about trust, reconciliation, and the fact that finance people will find the discrepancy long before you do, and they will not be charmed by it. Handling rounding correctly across multiple currencies is one of those problems that looks trivial for about a day and then reveals itself to be genuinely deep. Here is what I have learned running payment systems that touch a couple dozen currencies.
Money is not a float, and I will die on this hill
The first rule, the one I will not negotiate on, is that you never represent money as a binary floating point number. Not a double, not a float, not "just for the display layer." The reason is old and boring: 0.1 has no exact representation in IEEE 754, so the moment you add a few of them together you get 0.30000000000000004 and your rounding logic starts making decisions based on noise. I have seen a pricing engine that used doubles internally and produced correct answers 99% of the time, which is worse than being wrong all the time, because nobody trusts a bug they cannot reproduce.
In .NET the answer is decimal, which stores base-10 fractions exactly up to 28-29 significant digits. In other stacks you use an arbitrary-precision decimal type or, my preferred approach for anything that crosses a service boundary, integer minor units. Store €33.33 as the integer 3333. Addition and subtraction become exact by construction, serialization is unambiguous, and you never have to wonder what a JSON number with a trailing 9 was supposed to mean. The tax you pay is that you have to remember the scale for each currency, which brings us to the part everyone gets wrong.
The "two decimal places" assumption is a bug waiting to ship
Almost every money system I have inherited hardcodes two decimal places somewhere. It is usually buried in a formatting helper, and it is usually wrong for a meaningful slice of the world. The number of minor units — the digits after the decimal point — is a property of the currency, not a universal constant.
- Most currencies use 2 decimals: USD, EUR, GBP.
- Japanese yen (JPY) and Korean won (KRW) use 0. A yen is the minor unit. ¥100 is the integer 100, not 10000.
- Bahraini dinar (BHD), Kuwaiti dinar (KWD), and Omani rial (OMR) use 3. One dinar is 1000 fils.
- Chilean peso (CLP) is listed with 0 minor units under ISO 4217, and there are genuine oddballs like the Malagasy ariary, one of the last non-decimal currencies, subdivided into five iraimbilanja rather than into tenths.
The authoritative source for this is ISO 4217, and you should treat its minor-unit table as reference data you load, not as knowledge you encode by hand. I once watched a settlement job multiply every yen amount by 100 because someone assumed cents. We over-reported a batch by two orders of magnitude to a reconciliation feed. It was caught, but only because the totals were so absurd a human blinked. Do not rely on absurdity as a control.
Round at boundaries, not in the middle
The single most useful discipline I can offer is this: carry full precision through your calculations and round exactly once, at a defined boundary. A boundary is a point where a number becomes real to someone — a stored ledger entry, a line on an invoice, an amount you send to a payment rail. Everywhere else, keep the extra digits.
The classic failure mode is rounding early and then doing arithmetic on the rounded values. You compute a per-unit price, round it to the cent, multiply by quantity, and now your rounding error is multiplied by the quantity too. Order 1,000 units of something priced at $0.125 and the difference between rounding first ($120.00 or $130.00 depending on your mode) and rounding last ($125.00) is five to ten dollars. That is not a rounding error anymore, that is a pricing bug with a paper trail.
Round once, at the edge of the system, where the number stops being a calculation and starts being a promise.
Banker's rounding versus half-up, and why the default bit me
Here is a detail that has cost me real hours: the rounding mode. When a value lands exactly halfway — say $2.125 to two decimals — you have to decide which way it goes. Round-half-up sends it to $2.13. Banker's rounding (round-half-to-even) sends it to $2.12, because 2 is the even neighbor, and it sends $2.135 to $2.14. Banker's rounding exists to remove the statistical bias that always-rounding-up introduces across large batches.
The trap is that Math.Round in .NET defaults to banker's rounding, while most accountants, most tax authorities, and most of your users expect half-up because that is what they learned in school. So your code and your finance team disagree by a cent on exactly the values that are hardest to explain. I do not think either mode is universally correct. What I think is that the mode must be an explicit, documented decision per context, never a language default you inherited by accident. Write it down. Put it in the ledger's specification.
Enjoying this article?
Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.
// Be explicit. Never let the framework default decide money for you.
public static decimal RoundMoney(decimal amount, int minorUnits)
{
return Math.Round(amount, minorUnits, MidpointRounding.AwayFromZero);
}
// $2.125 with 2 minor units -> 2.13 (half-up / away-from-zero)
// vs the .NET default (ToEven) which would give 2.12
var line = RoundMoney(2.125m, 2);
The penny that goes missing: allocation
Back to my three-way €100 split. The correct fix is not to round each share independently and hope. It is to allocate: divide the total, and then distribute the leftover minor units deterministically so the parts always sum back to the whole. The common technique is largest-remainder — give the spare cents to the shares whose truncated remainders were biggest. The rule you must guarantee is simple and non-negotiable: the pieces sum exactly to the original.
public static long[] Allocate(long total, int parts)
{
var result = new long[parts];
long baseShare = total / parts;
long remainder = total - (baseShare * parts);
for (int i = 0; i < parts; i++)
{
result[i] = baseShare + (i < remainder ? 1 : 0);
}
return result; // Allocate(10000, 3) -> [3334, 3333, 3333], sums to 10000
}
Notice this works in integer minor units and is impossible to get wrong by a cent, because the remainder is handed out explicitly rather than lost to truncation. For weighted splits — allocating by percentage, or by line-item value — the same idea holds: floor every share, then distribute the remaining units to the largest fractional remainders. Which specific share receives the odd cent is arbitrary, but it must be deterministic, because a reconciliation run tomorrow has to produce the identical answer it produced today.
FX conversion is where rounding meets money you cannot un-spend
Converting currencies adds a second rounding decision on top of the first, and now real value can leak. You take an amount in USD, apply a rate, and round to the target currency's minor units. Two questions immediately matter: how many decimals does your FX rate carry, and do you round the converted amount before or after fees? I have seen systems store rates to 4 decimals when the trade actually executed at 6, and the accumulated drift over a day of high volume turned into a four-figure unexplained variance against the liquidity provider.
My rule is to store rates at the precision you actually receive them, keep the converted amount at full precision until the boundary, and round to the destination currency's minor units exactly once. When converting into a 0-decimal currency like yen, that final rounding step is doing real work — the difference between rounding $10.00 worth of yen up or down is a whole yen, not a fraction of a cent. And never, ever round the rate to make the math tidy. The rate is an input, not a place to hide error.
Store the currency, the scale, and the mode — every time
A monetary value with no currency attached is not money, it is a number cosplaying as money. Every amount in a ledger, an API payload, or a database row should travel with its currency code. My hard preference is to store minor-unit integers alongside an explicit currency, and to reconstruct the decimal for display rather than storing a float and trusting it.
In a schema I keep the amount and the currency inseparable, and I resist the temptation to use a floating column type even when the ORM makes it easy:
CREATE TABLE ledger_entry (
id BIGINT PRIMARY KEY,
amount_minor BIGINT NOT NULL, -- e.g. 3333 for EUR 33.33
currency CHAR(3) NOT NULL, -- ISO 4217, e.g. 'EUR', 'JPY'
created_at TIMESTAMPTZ NOT NULL
-- amount_minor is exact; the display scale comes from `currency`
);
The audit benefit is enormous. When finance asks why entry 4471 shows what it shows, you can replay the exact integer arithmetic. There is no "well, it was a double and the display layer rounded it" hand-waving, which is a sentence that has never once satisfied an auditor.
Test the ugly numbers, because production will
Rounding bugs do not live in the round numbers. They live in $0.005, in three-way splits of prime-ish totals, in yen and dinar, in the values that sit exactly on the midpoint. So that is precisely what your tests should hammer. I keep a property-based test that asserts the invariant that matters most: for any total and any split, the allocated parts sum back to the total, in every currency scale we support.
A few cases I make sure are always covered, because each one has burned me or someone I worked with:
- Splitting an amount that does not divide evenly (100 / 3, the original sin).
- Zero-decimal currencies, to catch the hardcoded "2" hiding in a helper.
- Three-decimal currencies, which almost no test suite includes by default.
- Exact midpoint values, to confirm the rounding mode is the one you actually chose.
- Negative amounts — refunds round too, and away-from-zero behaves differently from half-up on the negative side.

Conclusion
If I had to compress two decades of this into one instruction for a new engineer, it would not be a formula. It would be a warning: nobody will thank you for getting rounding right, but a spreadsheet three floors away is quietly waiting to catch you getting it wrong. Money arithmetic is a domain with real authorities, real invariants, and real people downstream. So make the type, the scale, the mode, and the boundary explicit decisions, write them into the ledger's specification, and back them with tests that hammer the ugly midpoints. Do that, and the one-cent tickets stop arriving. Skip it, and you will not find out until an accountant does — and by then it is not a bug, it is a conversation about whether your numbers can be trusted at all.
Get new posts in your inbox
Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.
Comments (0)
Leave a Comment
No comments yet. Be the first to comment!
