Float for money anti-pattern
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 accumulate. Multiplied across millions of transactions, a $0.0000001 rounding error becomes a compliance nightmare.
- 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). - Every major payment processor (Stripe, PayPal) stores monetary amounts as integers in the smallest currency unit and specifies the currency to determine the divisor.
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 triggers an audit. The audit finds hundreds of individual transactions with $0.001 rounding errors. Your financial records are inaccurate, your books won't close cleanly, and SOX compliance is now a problem.
I saw this exact failure at a fintech company in 2022. The root cause took two engineers three weeks to trace because the rounding error per transaction was invisible. It only became visible in aggregate.
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.
How to Detect It
| Symptom | What It Means | How to Check |
|---|---|---|
| End-of-month reconciliation off by pennies | Accumulated float rounding across millions of rows | SELECT SUM(amount) - expected_total FROM transactions |
FLOAT or REAL columns in financial tables | Schema using inexact types for money | SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'orders' AND data_type IN ('real', 'double precision', 'float') |
number type used for money in application code | No decimal library, raw float arithmetic | Grep codebase for price *, amount *, total + with no BigDecimal/Decimal wrapper |
| Unit tests pass but integration reconciliation fails | Per-transaction error invisible, aggregate error visible | Run reconciliation against a known-good ledger with 100K+ rows |
| Currency conversion produces 15-digit decimals | Float artifacts leaking into API responses | Check API responses for amounts like 9.990000000000002 |
The Fix
Fix 1: Integer cents (the Stripe approach)
Store all amounts as integers in the smallest currency unit. Divide only for display.
// Store: amount in cents
const price: number = 999; // $9.99 stored as 999 cents
// API request/response
{ "amount": 999, "currency": "usd" }
// Display only
const display = `$${(price / 100).toFixed(2)}`; // "$9.99"
// Arithmetic stays integer
const tax = Math.round(price * 0.08); // 80 cents, exact integer
const total = price + tax; // 1079 cents = $10.79
Integer arithmetic is exact. No rounding errors. Works in every language. This is the approach I recommend as the default for any new system that handles money.
Fix 2: DECIMAL/NUMERIC SQL type
PostgreSQL, MySQL, and SQL Server all have DECIMAL(precision, scale) or NUMERIC types that use exact fixed-point arithmetic.
CREATE TABLE orders (
id UUID PRIMARY KEY,
amount DECIMAL(12, 2) NOT NULL, -- up to 9,999,999,999.99 exactly
tax DECIMAL(12, 4) NOT NULL, -- 4 decimal places for intermediate calculations
total DECIMAL(12, 2) NOT NULL
);
Never use FLOAT or REAL SQL types for monetary columns. DECIMAL is exact; FLOAT is not.
Trade-off: DECIMAL arithmetic is slower than native float operations (roughly 2-5x in benchmarks). For payment systems doing thousands of calculations per second, this is irrelevant. Correctness matters more than microseconds.
Fix 3: Decimal library (for application-layer math)
When integers aren't practical (complex multi-step calculations with varying decimal scales), use a decimal library that implements exact decimal arithmetic.
from decimal import Decimal, ROUND_HALF_UP
price = Decimal('19.99')
tax_rate = Decimal('0.08')
tax = (price * tax_rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
total = price + tax
# total = Decimal('21.59') exactly
// Java BigDecimal equivalent
BigDecimal price = new BigDecimal("19.99");
BigDecimal taxRate = new BigDecimal("0.08");
BigDecimal tax = price.multiply(taxRate)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal total = price.add(tax); // 21.59 exactly
Java's BigDecimal, Python's decimal.Decimal, and JavaScript's big.js or decimal.js all implement this correctly.
Trade-off: Decimal libraries add verbosity. Every operation requires method calls instead of operators. They also prevent accidental mixing with float types, which is actually a feature. Always construct from strings (Decimal('19.99')) not floats (Decimal(19.99)), or you bake in the float error at construction time.
Which fix to use
Not all currencies have two decimal places. JPY (Japanese Yen) has 0 decimal places. KWD (Kuwaiti Dinar) has 3. Some cryptocurrencies have 8. Always store currency alongside the amount, and use the currency to determine the exponent:
{ "amount": 10050, "currency": "JPY" } // ¥10,050 (no cents)
{ "amount": 999, "currency": "USD" } // $9.99 (in cents)
{ "amount": 1099, "currency": "KWD" } // 1.099 KWD (in fils)
Severity and Blast Radius
The damage from floating-point money is cumulative and silent. A single transaction looks correct. The bug only surfaces during reconciliation, audits, or compliance reviews (weeks or months later).
- Financial reporting: Books won't close. SOX compliance violations.
- Tax calculations: Accumulated rounding in tax amounts triggers IRS discrepancies.
- Multi-currency conversion: Float errors compound with each conversion step. A USD-to-EUR-to-GBP chain can drift by $0.01+ per transaction.
- Recovery difficulty: You must recompute every affected transaction. If the float was the source of truth, some precision is permanently lost.
When It's Actually OK
- Display-only rounding: If you compute the exact amount using integers or DECIMAL, then round to 2 decimal places only for the UI string, the float representation of
"$9.99"is harmless because it never feeds back into calculations. - Analytics and dashboards: Approximate revenue totals on an internal dashboard where $0.04 variance on $48M is irrelevant. Never use float-sourced numbers for official financial reporting.
- Non-financial decimals: Percentages, ratings, sensor readings, GPS coordinates. Floating-point is perfectly fine when exact decimal precision is not a business requirement.
How This Shows Up in Interviews
When designing any system that handles money, state explicitly: "All monetary values are stored as integers in the smallest currency unit, cents for USD. We never use floating-point types for financial amounts anywhere in the system."
The three-sentence framework
"We store all amounts as integer cents. The database column is BIGINT or DECIMAL(12,2), never FLOAT. We divide by 100 only at the display layer." This is a clear signal you have worked with financial systems.
Quick Recap
- IEEE 754 floating-point cannot represent many decimal fractions exactly. Rounding errors accumulate across millions of transactions.
- The correct default: store monetary amounts as integer cents. Divide only at the display layer.
- SQL
DECIMAL/NUMERICtypes are exact. SQLFLOATandREALtypes are not. Never use float column types for money. - At the application layer, use decimal arithmetic libraries (BigDecimal, Python Decimal) for any monetary math.
- Always store the currency alongside the amount and use the currency to determine the number of decimal places.
- The bug is invisible per-transaction and only surfaces in aggregate reconciliation, which makes it especially dangerous.