17 // journal

Floats lose money, so store amounts as integers and convert at the edge

engineering · 2026-11 · 5 min read · engineering · money · databases

journal/store-money-as-integers
Floats lose money, so store amounts as integers and convert at the edge

fig.01 · stripe's zero-decimal list, the same integer convention the whole API uses

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 precision carries 15 decimal digits and is inexact; numeric is 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 amount values 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

STORE
smallest unit as integer: 1999 = $19.99, 500 = ¥500
column type bigint or numeric(19,0), never real/double
UNIT SIZE
currency metadata, not a constant
jpy + 15 more: unit = 1; isk/ugx charge as x00
DIVIDE
splits leave a remainder; pick a rule, name it, log it
RATES
fx, tax, percent stay fractional on purpose
multiply once, round once, record the rounding mode
EDGES
decimals allowed only at parse-in and format-out
API
amount is a positive integer in the smallest unit;
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.

// 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.

← the journal