Every app that keeps a balance eventually hits the same wall: the numbers stop adding up. A wallet that credits 0.10 a hundred times ends up holding 9.99999999999998. An invoice total refuses to equal the sum of its lines. The bug is not in your code, it is in the number type, and it is silent until reconciliation.
The constraint
A ledger has one job: add up exactly. Charges, refunds, splits, fees, balances. Every row has to reconcile to the smallest unit of the currency, every time, or someone calls support. There is no "close enough" in accounting, so the storage type has to be exact, not just usually right.
The complication is that most currencies are decimal (dollars and cents, pounds and pence) while the hardware's native number type is binary. Those two systems disagree about which fractions exist.
The options
Float. Every JS number and every Postgres double precision is IEEE 754 binary64: a 53-bit significand in base 2, roughly 15 to 17 significant decimal digits. In binary, 0.1 has no finite representation; David Goldberg's canonical paper puts it plainly: "the decimal number 0.1 cannot be represented exactly." Every tenth of a dollar you store is already rounded, and the error compounds in every sum. Rejected on silence, not magnitude: the drift is small enough to pass tests and large enough to fail an audit.
Decimal in the database. Postgres numeric is "especially recommended for storing monetary amounts," exact up to 131,072 digits before the decimal point. This is a solid choice, with two catches. The docs warn it is "very slow compared to the integer types," and it only protects the column: the moment the value crosses into JS, JSON, or a spreadsheet, it is a float again. The database cannot fix a type that only exists inside it.
Integer minor units. Store 1999, display $19.99. Exact by construction, fast, sortable, sums perfectly, and it is the convention the payment industry already ships: Stripe's API "expects currency values using the given denomination's smallest unit represented without decimals," so amount: 1000 charges $10.00. The real rejection reasons are elsewhere: division is not closed over integers, and the unit size is per-currency metadata, not the constant 100. Both are solvable, and both are below.
The mechanism
Run this anywhere JS runs; these are the actual outputs from Node:
const price = 0.1 + 0.2;
console.log(price); // 0.30000000000000004
console.log(price === 0.3); // false
let wallet = 0;
for (let i = 0; i < 100; i++) wallet += 0.1;
console.log(wallet); // 9.99999999999998
// the same math in integer minor units
const cents = 10 + 20;
console.log(cents === 30); // true
// the honest edge: splitting is not closed over integers
const bill = 100;
const share = Math.floor(bill / 3); // 33
console.log(share * 3 === bill); // false: one cent needs a home
The last three lines are the entire convention: multiply once at ingestion, store and sum integers forever, divide once at display. Nothing in between is fractional.
Where it bites
Integer storage fails in three places, and each has a known rule.
Division leaves a remainder. A 100-cent bill split three ways produces 33, 33, and an orphaned cent. Pick the rule before you ship: largest remainder, first share keeps the extra, round robin. What you cannot do is divide and float, because that reimports the exactness problem through the back door.
The unit is currency metadata, not the number 100. Stripe's zero-decimal list covers 16 currencies where the charge and the amount are identical: 500 JPY is amount: 500, no multiplication. The special cases are stranger: ISK and UGX are zero-decimal but must still be submitted as two-decimal x00 values, and HUF or TWD manual payouts must be integers divisible by 100. Hardcode amount / 100 and your yen prices read 100 times too large.
Rates are fractional. FX quotes, tax percentages, and fee splits do not fit in integers. Keep the ledger integer, keep the rate as a decimal or as basis points, multiply once, round once, and write down which rounding mode you used. The undocumented Math.round on a fee is where "money bug" tickets are born.
The numbers
The limits are documented, not anecdotal:
double precisioncarries 15 decimal digits and is inexact;numericis exact to 131,072 digits and pays for it in arithmetic speed.- JS integers stop being exact past
Number.MAX_SAFE_INTEGER, 2^53 minus 1, or 9,007,199,254,740,991 per MDN. In cents that is about $90 trillion, so the ceiling is not your problem; the fraction is. - Stripe caps most card
amountvalues at 12 digits of minor units, a maximum of 999,999,999,999, roughly $10 billion in USD. Every serious payment API picked integer minor units for a reason.
Our position
Store every persisted amount as an integer in the currency's smallest unit. Accept decimal storage only for things that are inherently fractional: rates, conversion factors, percentage splits. Let decimals exist at exactly two boundaries, parsing user input and formatting for display, and nowhere else.
The flip side holds too: if your schema has a double precision column feeding a balance, that is a defect even if nothing has drifted yet. The rounding error is guaranteed by the format; the only question is when it surfaces.
The takeaway
column type bigint or numeric(19,0), never real/double
jpy + 15 more: unit = 1; isk/ugx charge as x00
multiply once, round once, record the rounding mode
your own API contract should read the same way
This is the level of pedantry money deserves, and the kind of data-modeling decision we make on day one of our custom software builds. If your ledger already has float columns and you want the migration scoped before it becomes an audit finding, send us the schema.
// sources
// next step
We work this way on client projects too.
Tell us what you are making. We reply with a plan, a timeline, and a price.
