The Silent Killer of Financial Systems
We've all heard the war stories: a trading platform loses millions to rounding errors, a payment processor double-charges customers, or an accounting system reports inconsistent balances across regions. The culprit is almost always the same—floating-point arithmetic creeping into financial calculations where it has no business being. Precision math in finance apps: best practices explained is not optional complexity; it's the foundation that separates trustworthy systems from disasters waiting to happen.
This is day 83 of our 100-day Engineering Craft series, and we're diving deep into how to handle money correctly in code. The mathematics themselves are simple, but the engineering discipline required is surprisingly rigorous. By the end of this article, you'll understand not just what to do, but why each decision matters in production systems handling real currency.
Why Floating Point Is Wrong for Money
Let's start with the problem. Floating-point numbers (IEEE 754) are designed for scientific computation where approximate answers are acceptable. Financial systems require exact answers. When you add 0.1 + 0.2 in most programming languages, you don't get 0.3—you get something like 0.30000000000000004. That tiny error is irrelevant in physics simulations but catastrophic in finance.
Here's the practical impact: a stock trading system processing 10,000 trades per day can accumulate errors in the sub-cent range that eventually cause reconciliation failures. An e-commerce platform calculating taxes on millions of orders might generate systematic overbilling. A payment processor routing millions in transfers can lose track of pennies across millions of transactions, which add up quickly.
The core issue is that decimal fractions like 0.1 or 0.01 cannot be represented exactly in binary floating point. They're stored as approximations, and these approximations compound through arithmetic operations.
Store Amounts in Minor Units or Decimals
The first and most important rule: never store or calculate financial amounts using floating-point types. You have two solid approaches, and each has appropriate use cases.
Minor Units Approach
Store everything as integers in the smallest unit of currency. For USD, that's cents. For Bitcoin, that's satoshis. This is the traditional approach used in banking systems for decades.
// Good: store as cents (integer)
interface OrderLineItem {
productId: string;
quantity: number;
priceInCents: number; // Always integer
discountInCents: number;
taxInCents: number;
}
// Calculate total
function calculateLineTotal(item: OrderLineItem): number {
const subtotal = item.quantity * item.priceInCents;
const afterDiscount = subtotal - item.discountInCents;
const total = afterDiscount + item.taxInCents;
return total; // Returns integer cents, exact
}
// Bad: never do this
interface BadOrderLineItem {
priceUSD: number; // Floating point! Wrong.
discountUSD: number;
}
The advantage here is speed and simplicity. Integer arithmetic is fast and exact. The disadvantage is that you must handle currency conversion explicitly and ensure all code in your system follows this convention consistently. One developer storing prices in dollars as floats breaks the entire approach.
Decimal Type Approach
Many modern languages provide decimal or bigdecimal types specifically designed for this problem. JavaScript, which famously lacks a native decimal type, requires libraries. TypeScript/Node.js developers typically use Decimal.js, Big.js, or similar.
// Good: use Decimal type
import Decimal from 'decimal.js';
interface OrderLineItem {
productId: string;
quantity: number;
priceUSD: Decimal;
discountUSD: Decimal;
taxUSD: Decimal;
}
function calculateLineTotal(item: OrderLineItem): Decimal {
const subtotal = new Decimal(item.quantity).times(item.priceUSD);
const afterDiscount = subtotal.minus(item.discountUSD);
const total = afterDiscount.plus(item.taxUSD);
return total; // Exact decimal, not approximation
}
// When retrieving from database, convert to Decimal
const price = new Decimal('29.99'); // Pass as string, not number
The decimal approach is more flexible because you can store prices in their natural units (dollars and cents) without conversion gymnastics. It's more readable and less error-prone than managing multiple integer types. The trade-off is a slight performance cost and the need to import a library.
Choosing Between Approaches
Use minor units (integers) if:
- Your system handles only a single currency
- Performance is absolutely critical (high-frequency trading)
- Your team has strong financial engineering discipline
- Your database and ORM natively support big integers
Use decimal types if:
- You support multiple currencies with conversion
- Your codebase is predominantly JavaScript/Python
- Readability and reduced bug surface matter more than sub-millisecond performance
- You need to round to arbitrary precision (not just two decimal places)
In our experience building fintech products, we lean toward decimal libraries for most new systems. The safety gain justifies the modest performance cost.
Define Rounding Rules Explicitly
Money always has a fixed decimal precision—usually two digits. When you calculate totals, apply discounts, or split amounts, you frequently need to round. The question is how.
IEEE 754 specifies five rounding modes. Financial systems typically use Round Half Up (also called banker's rounding or round-to-even in some contexts, but that's different). Your goal is to make the rounding rule explicit and consistent across the entire system.
import Decimal from 'decimal.js';
// Define your rounding strategy once, in one place
const ROUNDING_MODE = Decimal.ROUND_HALF_UP;
const CURRENCY_DECIMAL_PLACES = 2;
function roundToMoney(value: Decimal): Decimal {
return value.toDecimalPlaces(CURRENCY_DECIMAL_PLACES, ROUNDING_MODE);
}
// Example: tax calculation
function calculateTax(subtotal: Decimal, taxRate: Decimal): Decimal {
const taxAmount = subtotal.times(taxRate);
return roundToMoney(taxAmount); // Explicitly rounded
}
// Example: splitting an amount
function splitAmountEvenly(total: Decimal, parts: number): Decimal[] {
const perPart = total.dividedBy(new Decimal(parts));
const rounded = roundToMoney(perPart);
// Distribute rounding errors to the last part
const result = Array(parts).fill(rounded);
const sum = rounded.times(new Decimal(parts));
const remainder = total.minus(sum);
result[result.length - 1] = result[result.length - 1].plus(remainder);
return result;
}
A critical detail: when you split an amount (common in invoicing, refunds, or commission distribution), simple rounding of each part often doesn't sum back to the total. The classic solution is to round each part, then assign the accumulated rounding error to the last part. This is standard practice in accounting systems.
Document your rounding rules in code comments and, ideally, in a shared specification or wiki. When a customer disputes a charge, you need to explain—with evidence—exactly how every dollar and cent was calculated.
Use Decimal Libraries for Calculations
Once you've chosen your representation (integers or decimal types), your calculations must respect that choice throughout. Never mix floating point and fixed-point arithmetic. Never convert to float for "just one calculation."
Here's a real scenario from an e-commerce platform:
// Bad: mixing types
function calculateOrderTotal(lineItems: LineItem[]): Decimal {
let total = 0; // JavaScript number—floating point!
for (const item of lineItems) {
total += item.priceInCents; // Mixing number and cents
}
return new Decimal(total); // Damage already done
}
// Good: consistent decimal operations
function calculateOrderTotal(lineItems: LineItem[]): Decimal {
let total = new Decimal(0);
for (const item of lineItems) {
total = total.plus(new Decimal(item.priceInCents));
}
return total;
}
// Good: even cleaner with reduce
function calculateOrderTotal(lineItems: LineItem[]): Decimal {
return lineItems.reduce(
(sum, item) => sum.plus(new Decimal(item.priceInCents)),
new Decimal(0)
);
}
Use your library's methods for every operation: plus(), minus(), times(), dividedBy(). Don't fall back to native operators even when tempted. Never convert to a regular number midway through a calculation.
In .NET, use decimal type throughout financial code. In Python, use Decimal from the standard library. In Java, use BigDecimal. In PostgreSQL, use NUMERIC or DECIMAL columns, never floats.
Test Edge Cases Like Tiny Lot Sizes
Financial systems handle a wide range of edge cases that simple CRUD applications never encounter. Your test suite must cover scenarios like:
Very Small Amounts
Cryptocurrency transactions, stock fractional shares, and micropayments require handling amounts smaller than your normal currency unit.
describe('Tiny lot size calculations', () => {
it('handles Bitcoin fractions correctly', () => {
const satoshi = new Decimal('0.00000001'); // One satoshi
const amount = satoshi.times(new Decimal(150));
expect(amount.toString()).toBe('0.0000015');
});
it('splits one cent across three recipients without loss', () => {
const oneCent = new Decimal('0.01');
const parts = splitAmountEvenly(oneCent, 3);
const sum = parts.reduce((a, b) => a.plus(b), new Decimal(0));
expect(sum).toEqual(oneCent);
});
it('handles rounding in currency conversion', () => {
// 1 USD to JPY at 110.5 yen per dollar
const usd = new Decimal('1.00');
const rate = new Decimal('110.5');
const jpy = usd.times(rate);
const rounded = roundToMoney(jpy); // Should round to whole number
expect(rounded.toNumber()).toBeGreaterThan(0);
});
});
Precision Loss in Conversions
When converting between currencies or units, precision loss is inevitable. Test that your system handles it predictably and documents the loss transparently.
Cumulative Rounding
Test scenarios where rounding happens multiple times. For example, an order has tax, shipping, and a discount—each requiring rounding at different stages.
it('handles cumulative rounding correctly', () => {
let total = new Decimal('100.00');
// Apply discount (20%)
const discount = total.times(new Decimal('0.20'));
total = total.minus(roundToMoney(discount));
// Apply tax (8.5%)
const tax = total.times(new Decimal('0.085'));
total = total.plus(roundToMoney(tax));
// Apply shipping
total = total.plus(new Decimal('5.99'));
// Verify the result makes sense
expect(total.toDecimalPlaces(2)).toEqual(total);
});
Zero and Negative Amounts
Test refunds, reversals, and credit scenarios. Test that operations on zero don't produce unexpected results.
Common Mistakes
We've seen these mistakes repeatedly across production systems:
Mistake 1: Storing floats in the database
The damage here is permanent. Once you've stored approximate values in your database, you can't recover the truth. Always use DECIMAL or NUMERIC columns in PostgreSQL, MONEY or DECIMAL(19,4) in SQL Server, DECIMAL in MySQL.
Mistake 2: Forgetting to convert user input
When a user enters "29.99" in a form, it arrives as a string. Convert it to your decimal type immediately, before any calculation. Don't let it touch floating-point arithmetic.
Mistake 3: Inconsistent rounding across systems
Your backend rounds one way, your frontend rounds another way, and a batch process rounds a third way. The results never reconcile. Centralize rounding logic and test that every system component uses it.
Mistake 4: Ignoring locale-specific currency handling
Some currencies don't use two decimal places. Japanese yen uses zero. Kuwaiti dinar uses three. Bitcoin uses eight. Don't hardcode currency precision; make it configurable and test multiple currencies.
Mistake 5: Insufficient test coverage for edge cases
A test suite that passes 99% of normal transactions but fails on unusual amounts is worse than no test at all—it gives false confidence. Dedicate time to edge case testing.
Key Takeaways
- Never use floating-point arithmetic for money. Choose either integer minor units or decimal libraries; both approaches work, but floating point doesn't.
- Store financial data consistently. Every developer on the team must follow the same representation strategy. Document it prominently.
- Define and centralize rounding rules. Make rounding explicit, test it thoroughly, and ensure every component of your system uses the same logic.
- Use decimal libraries or types exclusively. Don't mix representations mid-calculation. Let the library handle all arithmetic.
- Test ruthlessly, especially edge cases. Tiny amounts, currency conversion, splitting, cumulative rounding, negative values—cover all of them.
- Treat precision as a first-class concern. This isn't premature optimization; it's correctness. A financial system that's 99.9% accurate is 100% wrong.






Comments
Loading comments…