While working on our fintech system, I was studying how other financial platforms were designed and looking at what competitors were doing under the hood. One pattern kept showing up: storing monetary amounts not as decimal numbers, but as integers in a much smaller unit of the currency.
My first reaction was that it seemed overly complicated. Why not just store 12.50 and be done with it?
Then I sat down and really thought through what floating point numbers actually are, and that reaction changed quickly.
The JavaScript example everyone knows but ignores
Open any browser console and type this:
0.1 + 0.2
// 0.30000000000000004
Not 0.3. A number that is almost 0.3, but not quite.
This is not a JavaScript bug. It is IEEE 754, the standard for how virtually all programming languages and processors represent floating point numbers in binary. The short version: not every decimal fraction can be expressed exactly in binary. 0.1 in binary is actually an infinitely repeating fraction, so the computer stores the closest approximation it can fit in 64 bits. When you do arithmetic with those approximations, the rounding errors compound.
For most applications, this is fine. A color value or a physics simulation coordinate being off by 0.000000000000004 does not matter. You round it before displaying and nobody notices.
In a financial system, it is a different situation entirely.
Why small errors are not acceptable when money is involved
When you move money between accounts, the system has a mathematical obligation: the total amount must be conserved. What leaves one account must arrive at another. Nothing can be created or destroyed in the process.
If you are storing amounts as floats and doing arithmetic with them, precision loss can silently violate that. A transfer of $0.10 might be stored as 0.09999999999999999. Multiply that across thousands of transactions and the accumulated rounding errors become real discrepancies. Your reconciliation reports do not balance. Your auditors have questions. The ledger you rely on as the source of truth is quietly lying.
The other scenario is rounding behavior during calculations. Fee calculations, interest rates, split payments, and currency conversions all involve multiplication and division, which are the operations most likely to produce non-terminating decimal results. If you round those results at the wrong point in the calculation chain, you either consistently over-charge or under-charge by fractions of a cent. Across millions of transactions, that adds up to real money.
You cannot afford to lose or create money because of how a number is represented in memory. That is the constraint that changes everything.
Storing money as integers in micros
The solution we used was storing all monetary amounts as BIGINT in the database, representing values in micros.
One unit of currency equals 1,000,000 micros. So $12.50 is stored as 12,500,000.
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id),
type TEXT NOT NULL CHECK (type IN ('debit', 'credit')),
amount_micros BIGINT NOT NULL CHECK (amount_micros > 0),
currency CHAR(3) NOT NULL,
transaction_ref UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Why micros specifically and not cents? Because micros give you six decimal places of precision, which covers the majority of real-world financial operations: fractional cent fees, exchange rate conversions, and sub-cent interest calculations. Cents only give you two decimal places, which starts to feel tight the moment you need to do any serious calculation before rounding to the final settlement amount.
You might wonder why we chose BIGINT over PostgreSQL's native NUMERIC (or DECIMAL) type, which also offers exact arbitrary-precision arithmetic. While NUMERIC is completely safe from floating-point errors, BIGINT uses a fixed 8 bytes of storage, executes hardware-level integer math much faster at scale, and maps directly to native 64-bit integers (BigInt) in application memory without requiring third-party decimal libraries.
Integer arithmetic has no precision loss when adding or subtracting. 12500000 + 7500000 is exactly 20000000, always. There is no binary approximation happening, and no rounding errors accumulating across ledger transfers. The math is exact.
Getting calculations right, not just storage
Storing correctly is only half of it. The other half is making sure you calculate correctly before you store anything.
The order of operations matters enormously. Consider a 2.5% transaction fee on a $10.00 payment. In micros:
// Wrong: using standard JS numbers (64-bit floats under the hood)
const paymentFloat = 10 * 1_000_000;
const feeWrong = Math.round(paymentFloat * 0.025); // float multiplication
// Right: keep everything in BigInt, multiply first then divide
const MICRO = 1_000_000n;
const paymentAmount = 10n * MICRO; // 10_000_000n micros
const feeCorrect = (paymentAmount * 25n) / 1000n; // BigInt arithmetic, exact
// Result: 250_000n micros = $0.25
In JavaScript, BigInt is the right tool for this. Regular number in JS is a 64-bit float, so even if you store values as integers, doing arithmetic with large numbers using the regular number type can still introduce precision errors when values exceed Number.MAX_SAFE_INTEGER. On top of that, JavaScript will rightly throw a TypeError if you accidentally mix number and bigint in the same operation.
One important caveat with BigInt division is that it truncates toward zero rather than rounding (for example, 5n / 2n evaluates to 2n). When a fee or split calculation does not divide evenly, you must be explicit about how you handle the remainder. In financial systems, you should avoid standard "round half up" in favor of Banker's Rounding (round half to even) to prevent systematic bias over millions of transactions, or explicitly allocate remainder units when splitting an amount across multiple parties.
For PostgreSQL, BIGINT arithmetic is always exact, so additions and subtractions done in SQL are safe by default. But if you pull values out into Node.js, be careful: the pg driver returns BIGINT columns as strings by default so JavaScript doesn't silently corrupt them into floats. You need to parse them straight into BigInt:
// pg returns BIGINT as string to avoid JS float issues
const row = await db.query('SELECT amount_micros FROM ledger_entries WHERE id = $1', [id]);
const amountMicros = BigInt(row.rows[0].amount_micros); // Safe
Rounding, when you do need it, should happen as late as possible in the calculation chain. Calculate in micros, keep your numerators multiplied before dividing, and only round when committing a final entry or converting to a settlement amount. Rounding in the middle of a multi-step calculation means each intermediate step introduces its own error, and those errors stack.
Display is a separate concern from storage
Storing in micros means you need a conversion step when showing amounts to users. That is simple:
function microsToDisplay(micros: bigint, currencyCode: string): string {
const amount = Number(micros) / 1_000_000;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount);
}
microsToDisplay(12_500_000n, 'USD'); // "$12.50"
microsToDisplay(1_000_000_000n, 'JPY'); // "¥1,000"
The conversion to float only happens at the display layer, after all ledger calculations are done. Notice that we let Intl.NumberFormat automatically determine the decimal places based on the ISO currencyCode rather than hardcoding two decimal places. This ensures zero-decimal currencies like JPY or three-decimal currencies like KWD format properly out of the box.
This is the same separation of concerns that applied to the ledger and balance table I wrote about earlier. Storage and display are different problems. Mixing their concerns is where the bugs get in.
The broader lesson
This was a small design decision in the sense that it was one data type choice for one column. But the reasoning behind it touches something more fundamental about how carefully you have to think about data representation in financial systems.
In most backend work, approximate is fine. A user's session timestamp being off by a millisecond does not matter. A product rating average being 4.199999 instead of 4.2 does not matter.
Money has a different contract with correctness. Every cent has to be accounted for. Every calculation has to produce the same result regardless of which machine runs it, at what time, under what load. The system has to be able to prove where every amount came from and where it went.
Floating point cannot make that guarantee. Integers can.
That gap between "close enough" and "exactly right" is small in most software. In fintech, that gap is the entire job.