Floating-Point Surprises

Concepts covered: Unpacking

A Python float is 64 bits holding a binary fraction: a sum of fractions whose denominators are powers of 2, like 1/2 or 1/8. The decimal 0.75 is 1/2 + 1/4, so it fits exactly. The decimal 0.1 has no finite binary form: in base 2 it is 0.000110011001100... with 1100 repeating forever. The hardware keeps the leading digits and rounds off the rest, so the variable holds the nearest number the format can represent, which sits a hair above one tenth.

DecimalBinary expansionStored exactly?
0.50.1yes
0.250.01yes
0.750.11yes
0.10.000110011...no
0.20.00110011...no
0.30.0100110011...no

Decimal(0.1) from the decimal module prints the value a float really holds, digit for digit, because building a Decimal from a float copies the stored binary value without rounding it. Run the example and compare the sums: the inexact values carry their tiny errors into the addition, while the exact ones add cleanly.

from decimal import Decimal

print(Decimal(0.1))
print(Decimal(0.75))
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(0.5 + 0.25 == 0.75)

Output:

0.1000000000000000055511151231257827021181583404541015625
0.75
0.30000000000000004
False
True

Small errors add up

Each addition rounds its result to the nearest representable float, and those roundings do not cancel. Adding 0.1 to a running total 10 times walks the total away from the decimal you expect. A pipeline that adds up 100,000 fees of 10 cents as floats ends the day at 10000.000000018848 instead of 10000.0, and a reconciliation that tests with == reports a mismatch that no accountant can find.

total = 0.0
total += 0.1
total += 0.1
total += 0.1
total += 0.1
total += 0.1
total += 0.1
total += 0.1
total += 0.1
total += 0.1
total += 0.1
print(total)
print(total == 1.0)
0.9999999999999999
False

Compare with a tolerance

== on floats asks whether 2 stored binary values match bit for bit, so 0.1 + 0.2 == 0.3 is False. math.isclose(a, b) asks whether they differ by at most a relative tolerance, rel_tol=1e-09 by default: one billionth of the larger value's size. A relative tolerance scales with the numbers, so it works for 3 rupees and 3 billion bytes alike. It breaks next to zero: one billionth of 0 is 0, so even a microscopic drift fails. Pass abs_tol whenever one side can be 0.

import math

drift = 0.3 - 0.1 - 0.2
print(drift)
print(math.isclose(0.1 + 0.2, 0.3))
print(math.isclose(drift, 0.0))
print(math.isclose(drift, 0.0, abs_tol=1e-9))

Output:

-2.7755575615628914e-17
True
False
True

Rounding works on the stored value

round(x, 2) rounds the stored binary value, and it breaks exact ties toward the even digit, a rule called banker's rounding, so round(2.5) is 2. The literal 2.675 is stored as slightly less than 2.675, so it rounds down to 2.67 before any tie rule applies. For money or any figure a person audits, build a Decimal from a string, which keeps the decimal digits you typed, and choose the tie rule with quantize.

from decimal import Decimal, ROUND_HALF_UP

print(round(2.675, 2))
print(Decimal(2.675))
print(round(0.5), round(1.5), round(2.5))
price = Decimal("2.675")
print(price.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))

Output:

2.67
2.67499999999999982236431605997495353221893310546875
0 2 2
2.68

Integers too big for a float

Up to 2**53, which is 9007199254740992, a float can hold every whole number. Past that point the gap between neighbouring floats grows to 2, then 4, then more, so an integer that lands between them is rounded to a neighbour. A 64-bit ID that passes through a float can come back as a different ID, which is why Discord's API documentation says its snowflake IDs are always returned as strings.

order_id = 9007199254740993
print(float(order_id))
print(int(float(order_id)) == order_id)

Output:

9007199254740992.0
False
Do
  • Compare floats with math.isclose, adding abs_tol when a side can be 0
  • Build each Decimal from a string, as in Decimal("19.99")
  • Store money as integer cents or as Decimal
  • Keep IDs as int or str from source to warehouse
Avoid
  • Test floats with == or !=
  • Pass a float to Decimal() and expect the digits you typed
  • Expect round() to send every .5 upward
  • Let a parser read 64-bit IDs into floats

Both 0.125 and 0.375 are eighths, so both are stored exactly. What does this print?

print(round(0.125, 2), round(0.375, 2))

Totals are where the drift becomes a business problem. A refund ledger adds its amounts one at a time, and the payout it must match was added in a different order or computed elsewhere, so the 2 floats can disagree in their final digits: 0.1 + 0.1 + 0.1 prints 0.30000000000000004. A reconciliation written as math.isclose(refunds, payout) accepts that difference and still catches a missing refund, which is off by whole cents.

About this lesson

This section is part of the Python Expressions: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.