Why 0.1 + 0.2 isn't 0.3: real-world lessons and examples
If you've spent any time debugging financial calculations or precise measurements in JavaScript, you've likely encountered the infamous floating-point quirk where 0.1 + 0.2 doesn't equal 0.3. This isn't a bug in your code—it's a fundamental limitation of how computers store decimal numbers in memory. Understanding why this happens, where it matters most, and how to defend against it will save you from subtle bugs that slip past unit tests and into production systems handling real money or critical data.
Numbers are 64-bit floating point
JavaScript treats all numbers as 64-bit IEEE 754 double-precision floating-point values. This standard represents a number in three parts: a sign bit, an 11-bit exponent, and a 52-bit mantissa (significand). Unlike integers, which map precisely to binary representations, many decimal fractions cannot be represented exactly in this format.
Consider 0.1. In binary, this is a repeating decimal—like 1/3 being 0.333… in decimal. The IEEE 754 format must truncate this repeating pattern, storing an approximation instead of the exact value. Let's verify this:
console.log(0.1);
// 0.1
console.log(0.1.toFixed(20));
// "0.10000000000000000555"
console.log(0.2.toFixed(20));
// "0.20000000000000001110"
console.log((0.1 + 0.2).toFixed(20));
// "0.30000000000000004441"Each number carries a tiny error. When we add 0.1 + 0.2, those errors compound, resulting in 0.30000000000000004441 instead of exactly 0.3. This isn't a quirk of JavaScript alone—Python, Java, C, and nearly every language using IEEE 754 exhibit the same behavior.
The issue stems from the gap between decimal representation (base-10) and binary representation (base-2). Binary can precisely represent fractions like 0.5, 0.25, and 0.125 because they're powers of two. But 0.1 and 0.2 are not powers of two, so they suffer the fate of being rounded to the nearest representable value.
Tiny errors accumulate
A single operation introduces a rounding error measured in parts per quadrillion. In isolation, this seems harmless. But in systems that perform thousands or millions of calculations—trading algorithms, scientific simulations, or billing systems processing daily transactions—these tiny errors accumulate into serious problems.
Here's a real-world scenario: imagine a ride-hailing platform calculating surge pricing. Each ride multiplies a base fare by a surge factor, and each factor is a decimal. After processing thousands of rides, accumulated rounding errors can cause mismatches between what the driver sees, what the passenger is charged, and what the ledger records.
// Simulating surge pricing calculation over many rides
let totalCharged = 0;
let baseFare = 10.5;
for (let i = 0; i < 100; i++) {
const surgeFactor = 1.15;
totalCharged += baseFare * surgeFactor;
}
console.log(totalCharged);
// 1210.9999999999998 (expected: 1211)
console.log(totalCharged.toFixed(2));
// "1211.00"
// But if this is used in a loop or further calculations...
const expectedTotal = baseFare * 1.15 * 100;
console.log(expectedTotal);
// 1210.9999999999998
console.log(totalCharged === expectedTotal);
// true (but both are wrong)In this example, over 100 rides we've accumulated a discrepancy. When you multiply this pattern across millions of transactions, the error grows. More critically, equality checks fail silently because both values have the same rounding error—you compare two wrong numbers and think everything is fine.
Accumulation also occurs in iterative algorithms. A physics simulation stepping through time with many small increments, or a statistical calculation averaging thousands of data points, can see errors compound. The solution isn't to ignore the errors but to be intentional about when precision matters.
Compare with a tolerance
When floating-point errors are acceptable (most UI calculations, sensor readings with inherent noise, statistical aggregations), use tolerance-based comparisons instead of exact equality. Rather than checking if two numbers are exactly equal, check if they're within an acceptable range of each other.
// Bad: exact comparison
console.log(0.1 + 0.2 === 0.3);
// false
// Good: tolerance-based comparison
function floatEqual(a: number, b: number, tolerance: number = 1e-10): boolean {
return Math.abs(a - b) < tolerance;
}
console.log(floatEqual(0.1 + 0.2, 0.3));
// true
console.log(floatEqual(0.1 + 0.2, 0.3, 1e-15));
// false (stricter tolerance reveals the error)The tolerance value depends on your context. For graphics and animations, 1e-10 is often sufficient. For scientific calculations comparing results from different algorithms, you might use 1e-6. For anything involving money, tolerance-based comparison is insufficient—you need an entirely different approach.
A practical pattern in production systems: use a library that handles this for you. JavaScript's Number.EPSILON provides the smallest meaningful difference between numbers, though it's rarely the right tolerance for your specific problem.
// Using Number.EPSILON
function isEqual(a: number, b: number): boolean {
return Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b));
}
console.log(isEqual(0.1 + 0.2, 0.3));
// true
// But for most code, a fixed tolerance is clearer
const TOLERANCE = 0.0000001;
console.log(Math.abs((0.1 + 0.2) - 0.3) < TOLERANCE);
// trueWhen comparing arrays of floats (common in machine learning or data processing), apply tolerance per element and consider the aggregate error:
function arraysApproxEqual(
a: number[],
b: number[],
tolerance: number = 1e-10
): boolean {
if (a.length !== b.length) return false;
return a.every((val, i) => Math.abs(val - b[i]) < tolerance);
}
const results1 = [0.1, 0.2, 0.3];
const results2 = [0.1, 0.2, 0.1 + 0.2];
console.log(arraysApproxEqual(results1, results2));
// trueNever use floats for money
This is the cardinal rule: money is not a floating-point number. Ever. In any language, on any platform. A financial system stores currency in integers representing the smallest unit (cents, pence, satoshis). If your schema has a column storing USD as a float, you have a production risk.
Here's why: financial systems must be auditable and reversible. If one customer is charged 9.99 when they should be charged 10.00, you need to know exactly why and compensate them exactly. Floating-point errors make this impossible—you can't trace the discrepancy to a specific calculation because the error is embedded in the representation itself.
Consider a subscription billing system:
// Bad: storing price as float
interface BadSubscription {
id: string;
monthlyPrice: number; // 9.99
}
const price = 9.99;
const annualTotal = price * 12;
console.log(annualTotal);
// 119.87999999999999 (not 119.88)
// Correct: store as integer cents
interface GoodSubscription {
id: string;
monthlyPriceCents: number; // 999
}
const priceCents = 999;
const annualTotalCents = priceCents * 12;
console.log(annualTotalCents);
// 11988 (exactly right)
console.log(annualTotalCents / 100);
// 119.88 (exact when displayed)When you need to perform calculations, work entirely in the integer domain (cents). Only convert to a decimal display format when rendering to the user. This applies to every monetary calculation: fees, taxes, discounts, refunds, splits between parties.
For more complex financial operations, use a dedicated library:
// Using a decimal library like decimal.js
import Decimal from 'decimal.js';
const price = new Decimal('9.99');
const monthlyBilling = price.times(12);
console.log(monthlyBilling.toString());
// "119.88"
const withTax = monthlyBilling.times(1.08);
console.log(withTax.toString());
// "129.3504"
// Control rounding at display time
console.log(withTax.toFixed(2));
// "129.35"Major platforms handle this carefully. Your payment processor (Stripe, Square) stores amounts as integers. Your database should do the same, or use a decimal type (PostgreSQL's NUMERIC, MySQL's DECIMAL). Your API contracts should transmit amounts as strings or integers to prevent language-specific floating-point quirks.
Common mistakes
- Testing monetary logic with floats: Unit tests pass locally because you never hit the exact boundary conditions, but production fails when it does. Store test data as integers.
- Assuming toFixed() fixes the problem:
toFixed()returns a string for display. The underlying number is still wrong, and if you parse it back to a number, you're back to square one. - Comparing equality in loops: A loop checking if a calculated total equals an expected value may exit early or miss the exact moment due to rounding. Use tolerance-based comparisons or redesign to avoid the comparison.
- Mixing floats and integers in financial code: Convert everything to one representation (integers) at the boundary, work in integers, and convert back only for display. Never mix types mid-calculation.
- Forgetting that division creates new errors: Not only does
0.1 + 0.2introduce error, so does10 / 3or any non-terminating division in binary. When dividing amounts (e.g., splitting a bill), use integer division with a remainder or a decimal library.
Key takeaways
- JavaScript numbers are IEEE 754 64-bit floats; many decimals cannot be represented exactly in binary.
- Rounding errors are tiny per operation but accumulate significantly across thousands of calculations.
- For comparisons where precision matters but money doesn't, use tolerance-based equality (
Math.abs(a - b) < epsilon). - For financial calculations, store amounts as integers (cents) and use decimal libraries (decimal.js, big.js) for complex operations.
- Never compare floats with
===and never assumetoFixed()has fixed your number—it only formats the display. - In APIs and databases, transmit and store monetary amounts as integers or decimal types, never floats.






Comments
Loading comments…