Float for Money
Learn why floating-point arithmetic silently corrupts financial calculations, how to use fixed-point integers or DECIMAL types safely, and why every cent counts in a payment system.
TL;DR
- IEEE 754 floating-point cannot represent many decimal values exactly.
0.1 + 0.2 === 0.30000000000000004in JavaScript. This is not a language bug, it is how binary floating-point arithmetic works. - In financial systems, these tiny rounding errors can accumulate. Multiplied across millions of transactions, a $0.0000001 rounding error can become a reconciliation or compliance problem.
- The correct alternatives: integer cents (store $9.99 as 999), DECIMAL/NUMERIC SQL type (exact fixed-point), or a decimal library (Python's
Decimal, Java'sBigDecimal). - Payment APIs such as Stripe commonly represent monetary amounts as integers in the smallest currency unit and specify the currency to determine the divisor.
Introduction
Money is a domain where the representation is part of the business rule. A value that looks like 19.99 to a person may be stored as a binary approximation, and repeated calculations can turn that approximation into a mismatch between ledgers. The anti-pattern is using binary floating-point for amounts that must reconcile exactly.
Mental Model
Separate three concerns:
- Storage: keep the amount in the smallest currency unit as an integer, or use an exact DECIMAL/NUMERIC type.
- Calculation: use integer arithmetic or a decimal type with an explicit rounding policy.
- Display: format the exact result for people; do not feed the display representation back into financial calculations.
The key question is not whether a number has a decimal point. It is whether a one-centβor one-smallest-unitβdifference is allowed to change the business result.
The Problem
A developer writes the most natural code in the world:
// JavaScript Node.js backend, payment calculation
const subtotal = 19.99;
const tax = subtotal * 0.08; // 8% tax
const total = subtotal + tax;
console.log(total); // 21.589200000000002
You round to two decimal places and store 21.59 in your database. Your finance team expects 21.59. The customer's receipt shows $21.59. Everything looks fine.
Until you run end-of-month reconciliation. 2.3 million transactions, each with a tiny rounding error. Your system shows total revenue of $48,732,419.12. Your bank shows $48,732,419.08. A $0.04 discrepancy can trigger an audit or reconciliation investigation. The investigation finds hundreds of individual transactions with $0.001 rounding errors. Your financial records are inaccurate, your books may not close cleanly, and SOX controls may be affected.
In a fintech-style workload, the root cause can take two engineers three weeks to trace because the rounding error per transaction is invisible. It only becomes visible in aggregate.
Before and After
Before, the amount and tax calculation use binary floating-point:
const total = subtotal + subtotal * taxRate;
await db.insert({ total }); // FLOAT/DOUBLE
After, the system keeps the storage and rounding policy explicit:
const subtotalCents = 1999;
const taxCents = Math.round(subtotalCents * 8 / 100);
const totalCents = subtotalCents + taxCents;
await db.insert({ totalCents, currency: "USD" });
The second design still has a rounding decision for tax, but that decision is visible, testable, and applied to an exact integer representation.
Why It Happens
Developers reach for float or double because it is the default numeric type in every language. When you type 19.99 in JavaScript, Python, Java, or Go, the runtime stores it as an IEEE 754 double. Nobody has to opt in. It just happens.
Binary floating-point uses powers of 2 as the basis. The decimal 0.1 cannot be represented exactly in binary, the same way 1/3 cannot be represented exactly in decimal (0.333...). The binary representation is 0.0001100110011... repeating.
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
This is not a Python bug. It is the same in every language that uses IEEE 754 double-precision floats (JavaScript, Java float/double, C/C++ float/double, Go float64).
The individually-reasonable decisions that lead here:
- "The language handles numbers for me." True for counting integers. Dangerously wrong for decimal fractions.
- "I'll round at the end." Rounding a wrong number does not make it right. The accumulated error from upstream calculations carries through.
- "The error is tiny." Per transaction, yes. Across 2 million transactions per month, it is an audit finding.
- "Our tests pass." Your tests check one transaction at a time. The bug is only visible in aggregate.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.