Machines and programs
Floating point and money
Why 0.1 + 0.2 is not 0.3, how floats lose precision, and why money must be stored as exact integers or decimals.
Integers cannot hold 0.5 or 6.02 × 10^23. Floating-point numbers can. They store a number in binary scientific notation: a sign, an exponent and a set of significant digits. That makes them fast and wide-ranging, but most decimal values are only approximations. Money needs exact values, so it needs a different tool.
The big idea
An IEEE 754 double (float64, Python float, JavaScript number, Java double) uses 64 bits:
- 1 sign bit
- 11 exponent bits, the power of two
- 52 fraction bits, the significant digits. With an implicit leading 1, that is 53 bits of precision.
53 bits gives about 15 to 17 significant decimal digits. A float32 has 24 bits, about 7 digits. ML training uses float32 and lower precision for speed, but accounting must not.
Concept
Rounding error: the gap between a number you wrote and the nearest value the format can store. Decimal 1/3 is 0.3333…, which never ends, so it can't be stored exactly in decimal digits. Binary has the same problem with 1/10.
Walking through 0.1 + 0.2
Decimal 0.1 is a repeating pattern in binary, 0.0001100110011…. It gets rounded to the nearest double, which is slightly more than 0.1. Same for 0.2. Their sum is rounded again, and it lands one step above the double nearest to 0.3.
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> total = 0.0
>>> for _ in range(10):
... total += 0.1
>>> total
0.9999999999999999This is not a Python bug. Every language using IEEE 754 doubles gives the same answers.
Four rules that follow
- Never compare floats with
==. Compare with a tolerance, such asmath.isclose(a, b, rel_tol=1e-9). - Errors add up. Summing millions of values drifts. Use compensated summation like
math.fsum, or use integers. - Large plus small loses the small.
1e16 + 1.0 == 1e16isTrue, because at 10^16 neighboring doubles are 2 apart. - Rounding can surprise. In JavaScript,
(1.005).toFixed(2)returns"1.00", because 1.005 is stored as 1.00499999999999989….
The Patriot missile failure of 1991 shows the stakes. The system counted time in tenths of a second using fixed-point arithmetic, and the small error in 0.1 grew to about 0.34 seconds after 100 hours of uptime. That was enough to miss an incoming missile by hundreds of meters.
NaN ("not a number") is the other trap. 0/0 gives NaN, and NaN spreads through any arithmetic it touches. NaN == NaN is false, so a NaN can quietly break sorting and hash maps. Check for it with math.isnan(x).
Why money is never a float
A ledger that is off by a tiny fraction per transaction is off by real dollars after billions of transactions. Auditors do not accept "floating-point rounding". Use one of two approaches.
- Integer minor units. Store the smallest unit as an integer. $12.34 is
1234cents. Currencies differ: the Japanese yen has 0 decimal places and the Kuwaiti dinar has 3. Store the currency code with every amount. - Decimal types. Python's
Decimal, Java'sBigDecimaland PostgresNUMERIC(19,4)are exact in base 10, at some speed cost.
from decimal import Decimal, ROUND_HALF_EVEN
price = Decimal("19.99")
tax = (price * Decimal("0.0825")).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
print(price + tax) # 21.64Pass a string, Decimal("19.99"), not a float. Passing a float first bakes in the binary error. Also choose a rounding rule on purpose. Round-half-to-even avoids a steady upward bias across many transactions.
Common mistakes
- Storing money as
floatordouble. Use integer minor units or a decimal type. - Comparing floats with
==. Use a tolerance. - Building a
Decimalfrom a float. Build it from a string. - Ignoring NaN in data pipelines. One NaN can poison a sum or a model's loss.
Check yourself
- Why does
0.1 + 0.2 == 0.3returnFalse? Both 0.1 and 0.2 are rounded when stored, and the sum is rounded again. The result is one step away from the nearest double to 0.3. - How should a $19.99 price be stored in a ledger? As integer cents (1999), or as a decimal type built from the string "19.99", with the currency code.
Key takeaways
- Doubles hold about 15 to 17 significant digits and cannot store 0.1 exactly.
- Compare floats with a tolerance. Watch for NaN, and sum large lists with care.
- Store money as integer minor units or a decimal type, with a currency code and an explicit rounding rule.
Further learning
The Computerphile video on floating point explains why 0.1 cannot be stored exactly, in plain terms.
Next, read Unicode and UTF-8 to see how text is stored as numbers.
Related
Numbers, text and encodings
How integers, floats, text, timestamps and Base64 are really stored as bytes, and the production bugs each one causes. Start here for the data-representation map.
Binary, integers and overflow
How integers are stored as bits, why signed counters wrap around, and why byte order matters when you read raw binary.
Quantization
INT8, FP8, INT4 and FP4. Fewer bits shrink weights, activations and KV. Match the format to memory-bound decode or compute-bound prefill, then eval quality.
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.