Store money as integer cents, never as a float
The claim A floating-point number cannot represent ten cents exactly, so any system that stores money in a float column will, given enough transactions, produce totals that are off...
The claim
A floating-point number cannot represent ten cents exactly, so any system that stores money in a float column will, given enough transactions, produce totals that are off by a cent — and a cent that nobody can explain is a cent that costs a bookkeeper an afternoon and costs you their trust in the system. Store money as an integer count of the smallest currency unit, or as a fixed-precision decimal. Never as a float. This is not a preference; it is arithmetic.
Prove it to yourself in ten seconds
Open any language's console:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.1 + 0.1 == 0.3
False
This is not a bug in the language. It is how binary floating point works: 0.1 has no exact representation in base 2, exactly as 1/3 has no exact representation in base 10. The error is tiny per operation, but it accumulates, and it accumulates specifically in the direction of a discrepancy you cannot round away because you no longer know which of a thousand additions introduced it.
Where it bites in practice
Consider an invoice with 37 line items, each stored as a float. Sum them, apply 13% HST, and the total your application shows can differ by a cent from the total the accounting system computes from the same source data, because the two systems accumulated the floating-point error in a different order. Now a customer's paid invoice shows a one-cent balance owing, your automated dunning emails them about it, and you have spent real money and real goodwill on a rounding artefact.
The tax case is worse, because tax authorities specify rounding rules, and "the float happened to round the other way" is not a defence in an audit.
The two correct representations
Integer minor units. Store $19.99 as the integer 1999, representing cents. All arithmetic is integer arithmetic, which is exact. This is compact, fast, and unambiguous, and it is what most payment providers use in their APIs precisely for this reason.
CREATE TABLE line_items (
amount_cents bigint NOT NULL, -- 1999 = $19.99
currency char(3) NOT NULL -- 'CAD'
);
Fixed-precision decimal. Postgres numeric(12,2) stores the value as an exact decimal, not a float. Arithmetic is exact to the declared scale. This reads naturally in queries and is the right choice when you need more than two decimal places — unit prices at four decimals for per-gram pricing, for instance.
CREATE TABLE prices (
unit_price numeric(12,4) NOT NULL -- exact, 4 decimal places
);
Both are correct. Choose integer cents when every amount has the same two-decimal scale and you want the speed; choose numeric when scales vary or readability matters more than the marginal performance.
The currency must travel with the amount
An amount without a currency is a number, not money. 1999 is nineteen dollars and ninety-nine cents in Canada and something entirely different in a currency with no minor unit — the Japanese yen has no cents, so its "minor unit" is the yen itself, and 1999 means 1999 yen. Store the currency code beside every amount, and never add two amounts without first confirming they share one. A total that silently sums CAD and USD is worse than a crash, because it looks like an answer.
Rounding is a decision, so make it once
When you must round — splitting a total across line items, computing a percentage — the rule must be explicit and applied in exactly one place in the codebase. The classic error is rounding each line and then summing, which does not equal rounding the sum: three items at $0.335 round to $0.34 each, totalling $1.02, while the true sum $1.005 rounds to $1.01. Decide which your business and your tax authority require, write it in one function, and route every monetary rounding through it.
// one function, used everywhere; banker's rounding shown
function roundCents(amount) { /* round-half-to-even */ }
Fixing a system that already used floats
If money already lives in float columns, the values are already imprecise, so you cannot simply change the column type and trust the contents. Convert with an explicit rounding step, reconcile the converted totals against an authoritative source — last month's bank-matched statement is ideal — and only then switch the application to integer or numeric arithmetic. The migration is straightforward; the reconciliation is the part that matters, because it is your one chance to find the errors the floats have already introduced before you lock them into an exact type and inherit them forever.