Guide

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.

Python
>>> 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.9999999999999999

This 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 as math.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 == 1e16 is True, 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.

  1. Integer minor units. Store the smallest unit as an integer. $12.34 is 1234 cents. Currencies differ: the Japanese yen has 0 decimal places and the Kuwaiti dinar has 3. Store the currency code with every amount.
  2. Decimal types. Python's Decimal, Java's BigDecimal and Postgres NUMERIC(19,4) are exact in base 10, at some speed cost.
Python
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.64

Pass 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 float or double. Use integer minor units or a decimal type.
  • Comparing floats with ==. Use a tolerance.
  • Building a Decimal from 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

  1. Why does 0.1 + 0.2 == 0.3 return False? 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.
  2. 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.

Floating Point Numbers - ComputerphileComputerphile
What Every Computer Scientist Should Know About Floating-Point Arithmeticdocs.oracle.com

Next, read Unicode and UTF-8 to see how text is stored as numbers.