Money as a float is the bug you ship once: storing amounts as integers
The mistake
A multi-currency event-ticketing platform stored every price as a Float in its Prisma schema. Section pricing, order totals, refund amounts — all of them Float. The reason was the same reason every other Postgres-based product makes the same mistake: the prototype needed to display "₺199.50" and the developer remembered that floats can hold decimals while integers can't.
It worked for six months. The first symptom was a customer-support ticket: "I paid ₺199.50 for two tickets at ₺99.75 each and my receipt says ₺199.49." The team checked, found the discrepancy was real, blamed the front-end rounding, and shipped a patch that displayed the receipt total instead of recomputing it client-side. The ticket closed. The bug did not.
The second symptom, a month later, was a reconciliation discrepancy. Stripe's payout for the day's sales was ₺312,847.18. The platform's internal total of completed orders was ₺312,847.21. Three cents on twelve hundred orders. Nobody could explain where the three cents went. Finance ate them. The team filed it under "rounding noise."
This is the post about what those three cents actually were, why floats keep producing them, and the column-type migration that retired the bug.
How it usually arrives
Float-money bugs do not arrive as a crash. They arrive as discrepancies, and discrepancies are the easiest class of bug to rationalise.
The first sign is usually a totals mismatch between two ways of computing the same number. The order total stored on the Order row disagrees with the sum of the seat prices that should make it up. The Stripe charge amount disagrees with the local Order total. The end-of-day report disagrees with the per-order receipts. The discrepancies are always small — pennies, cents, kuruş. The reaction is always to round somewhere and move on.
The second sign is usually a tax issue. Tax is a percentage of a total, and percentages on floats compound the imprecision. The tax line on a receipt has to add up exactly to the Postgres column total minus the pre-tax subtotal, and on floats it does not — not because the math is wrong, but because the binary representation of 0.18 (or 0.07, or 0.20 — any non-power-of-2 fraction) is approximate.
The third sign is usually a refund. Refunding ₺99.75 should mean the customer gets back ₺99.75. If the original charge was ₺99.7499999999...97 because of float math, the refund either underpays the customer or overpays them, and the payment provider's refund endpoint either refuses the request or processes it with a one-cent discrepancy. Either way, somebody — the customer, finance, or both — gets a follow-up email.
Most teams miss the warning because the discrepancies are small enough that the temporary fix (round, then ship) feels proportional. The discrepancies are not the bug. They are the symptom. The bug is the storage type.
Why most teams miss it
Floats look like a reasonable money type because they hold decimals. The mental model is "an integer for whole dollars, a fraction for the cents, what could go wrong." What could go wrong is that floats are not decimal numbers — they are binary numbers with a decimal-shaped string representation. The number 0.1 cannot be expressed exactly in binary. Neither can 0.2. Neither can 0.01, the unit of currency for most receipts.
The mistake is most often made by senior engineers who know about IEEE 754 in the abstract but forget about it in the moment of choosing a column type. The Prisma autocomplete for "what type holds 99.75" surfaces Float. The autocomplete is correct that Float will hold "99.75" — what it does not surface is that the value will be 99.749999... when it leaves the database, and that addition and multiplication compound the error rather than cancel it.
The other reason teams miss it is that the bug stays small for a long time. Twelve hundred orders generated three cents of discrepancy. A platform doing twenty orders a day will take years to accumulate something worth investigating. By the time the team sees the pattern, the codebase has thousands of references to the float columns and the refactor looks scary.
A repo-specific example of the warning landing
The platform's first concrete encounter with the bug was a commit titled fix: amount float convert. The diff was a few lines in the payment service:
// services/PaymentService/StripeService.ts (before)
const amount = order.totalAmount * 100; // float * 100, then "convert" to cents
await stripe.paymentIntents.create({ amount, currency: 'try' });
The "convert" was the bug. order.totalAmount was a Float. The line order.totalAmount * 100 is a float multiplication. For most numbers this rounds correctly; for some — including, predictably, common ticket prices like 99.75 and 149.50 — it lands at 9974.99999... or 14949.99999..., which Math.round would catch but the original code did not. The patch was to add Math.round at the boundary:
// services/PaymentService/StripeService.ts (after)
const amount = Math.round(order.totalAmount * 100);
That patch ships and the bug stops being visible at the Stripe boundary. But it does not fix the upstream problem. The Order row still stores a Float. The seat-price sum that produced totalAmount still uses Float arithmetic. The tax calculation still operates on Floats. The patch closes the symptom — Stripe was the most-visible failure mode — without retiring the cause.
The mitigation — moves that retire the bug
Three moves, applied in order.
Convert the storage type from Float to Int (minor units). Every monetary column moves from Float (e.g. 99.75) to Int (e.g. 9975). The integer represents the price in the smallest indivisible unit of the currency — cents for USD/EUR, kuruş for TRY, satoshi for BTC. Integers in Postgres are exact. Integer arithmetic in JavaScript is exact (within Number's safe-integer range, which covers any sane order total).
// schema.prisma — before
model EventSession {
// ...
sectionPrices Json? // [{ sectionId, price: float }]
}
model Section {
startingPrice Float
}
model EventSessionOrder {
totalAmount Float
currency String
}
// schema.prisma — after
model EventSession {
sectionPrices Json? // [{ sectionId, priceMinor: int }]
}
model Section {
startingPriceMinor Int // e.g. 9975 = ₺99.75
}
model EventSessionOrder {
totalAmountMinor Int // e.g. 19950 = ₺199.50
currency String
}
The migration backfills by multiplying each float by 100 and rounding once. The application code stops reading totalAmount and starts reading totalAmountMinor.
Centralise display formatting at the boundary. Money is an Int everywhere in the codebase except the moment it touches a screen, a receipt, or a payment provider. At those three boundaries — and only those — a single helper converts to a formatted string.
// libs/money.ts
export function formatMoney(minor: number, currency: string, locale = 'tr-TR') {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(minor / 100);
}
export function toStripeAmount(minor: number) {
// Stripe expects minor units directly — pass the integer through.
return minor;
}
The point of the helper is not the formatting (which is one line of Intl.NumberFormat). The point is that there is exactly one place in the codebase where division by 100 happens. Every other line of code that touches a price uses the integer. Audit becomes possible.
Tax and totals operate on integers. The tax line is Math.round(subtotalMinor * taxRate). The line-item subtotal is seats.reduce((sum, s) => sum + s.priceMinor, 0). There is no float arithmetic anywhere in the order-total path. Refunds are integer subtractions from integer charges. Reconciliation reports use integer aggregates.
function computeOrderTotal(seats: { priceMinor: number }[], taxRate: number) {
const subtotalMinor = seats.reduce((sum, s) => sum + s.priceMinor, 0);
const taxMinor = Math.round(subtotalMinor * taxRate);
const totalMinor = subtotalMinor + taxMinor;
return { subtotalMinor, taxMinor, totalMinor };
}
The single Math.round is at the tax line because tax involves a percentage multiplication and produces a fraction that needs to be resolved into integer minor units. Everything else is exact addition.
CTA — the single check to run today
Run this query against your own database, replacing the column names with your money fields:
SELECT id, "totalAmount", ROUND("totalAmount" * 100) AS minor_attempt,
("totalAmount" * 100) - ROUND("totalAmount" * 100) AS drift
FROM "EventSessionOrder"
WHERE ABS(("totalAmount" * 100) - ROUND("totalAmount" * 100)) > 0.0001
LIMIT 100;
If this returns any rows, you have stored money whose minor-unit value does not round-trip cleanly through Float. Every row in the result is a candidate for the next discrepancy report. The fix is the migration above — once the minor-unit column lands, every value rounds exactly because the value is an integer to start with.
Trade-off
The migration is real work, on a model that probably touches every meaningful query in the application. The backfill needs a deploy. The application code needs a coordinated update. The display layer needs every ${order.totalAmount} swapped for ${formatMoney(order.totalAmountMinor, order.currency)}. For an established platform this is a sprint of careful refactoring, not an afternoon.
What you buy is exact arithmetic. Reconciliation reports match payouts to the cent. Tax lines add up. Refunds are subtractions that produce the same number every time. The category of customer-support tickets titled "I was charged the wrong amount" goes to zero. The category of finance discrepancies titled "rounding noise" disappears.
The one trade you keep is the cognitive overhead of remembering that money is "in cents." Every new developer needs the helper file pointed out, and the first time they console.log(order.totalAmountMinor) they need to know they are looking at an integer. The naming convention — Minor suffix on every column — is the smallest tax for the largest correctness gain.
Business impact
The slow leak of unreconciled cents has a business cost that is measurable but rarely measured. Finance spends time investigating discrepancies; the answer is always "rounding"; the time is not recovered. Customer support spends time explaining off-by-one cents on receipts; the answer is "it was a rounding issue and you were charged correctly"; the customer trust is not recovered.
The migration retires both. The platform's books match the payment provider's. The customer's receipt matches the customer's bank statement. Finance gets time back. Support gets time back. The team stops shipping Math.round band-aids and starts shipping features.
What to do next
If you are starting fresh, choose Int for money on the first migration and never touch a Float for currency. The helper file is fifteen lines of TypeScript. The naming convention (Minor suffix) is a five-minute team agreement.
If you have a Float-based system in production, the migration is real but bounded. Pick one column (the most-queried order-total field), add a Minor companion, backfill, dual-write for a release, swap the read path, and drop the Float. Repeat for the next column. The schema gets one column lighter and one bug-class lighter with each iteration.
The artifact to copy: Int for storage, Minor suffix on the column name, a formatMoney helper at the boundary, and a toStripeAmount helper at the payment edge. Four files of change. One class of bug retired.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox