Nothing broken here — just the arithmetic the ticket is complaining about. Run it if you have not seen it before; it is more convincing than being told.
print(0.1 + 0.2) # -> 0.30000000000000004
print(0.1 + 0.2 == 0.3) # -> False
print(f'{0.1:.20f}') # -> 0.10000000000000000555
import math
print(math.isclose(0.1 + 0.2, 0.3)) # -> True
print(math.isclose(1.0, 1.004)) # -> False
print(math.isclose(1.0, 1.004, abs_tol=0.005)) # -> True
- 0.1 is not 0.1
A float is a binary fraction, and a tenth cannot be written exactly in binary any more than a third can be written exactly in decimal. What gets stored is the nearest available value, which is very slightly wrong, and the error is real enough to survive addition. Nothing is broken — the number was never the number you typed.
- Never compare floats with ==
Two calculations that should agree mathematically can land on neighbouring floats, so == answers False for reasons that have nothing to do with your logic. The question to ask instead is 'are these close enough for what I am doing', and that means naming a tolerance. Being forced to name it is a feature: half a penny is a decision, not a detail.
- abs_tol is the one you want for money
math.isclose defaults to a relative tolerance — a proportion of the values — which is right for physics and wrong for prices, where 'close enough' is a fixed half a penny whether the basket costs £3 or £3,000. Worse, the relative default is 0 near zero, so isclose(0.0, 0.004) is False. Money needs abs_tol.
Print sum([0.1, 0.2]) before you change anything. The code is doing exactly what it says; the disagreement is between two numbers that look identical when printed normally.
Stop asking whether the two numbers are equal and start asking how far apart they are. The contract gives you the distance that matters: half a penny, which is 0.005.
Either abs(total - paid) <= 0.005, or math.isclose(total, paid, abs_tol=0.005). Plain math.isclose(total, paid) is not enough — its default tolerance is relative, so it is far too strict for the 'hair over' cases and meaningless at zero.
import math def is_paid_in_full(prices, paid): if paid < 0: raise ValueError(f’paid cannot be negative, got {paid}') total = sum(prices) return math.isclose(total, paid, ____=0.005)
import math
PENNY = 0.01
def is_paid_in_full(prices, paid):
if paid < 0:
raise ValueError(f"paid cannot be negative, got {paid}")
total = sum(prices)
return math.isclose(total, paid, abs_tol=PENNY / 2)
0.005 in the middle of a comparison is a magic number that the next reader has to reverse-engineer into 'half a penny'. PENNY / 2 says it out loud, and it is the line somebody will come and change when the currency does not have hundredths. A named constant turns an arbitrary float into a decision someone made on purpose.
abs(total - paid) <= 0.005 is correct here and perfectly readable. math.isclose earns its place by also handling infinities and by making the choice between absolute and relative tolerance explicit at the call site — the reader can see which kind of 'close' was meant, which is exactly the thing this bug was about.
Every tolerance is a patch over a representation that cannot hold prices. Storing pence as integers makes the equality exact again and this whole class of bug disappears — 10 + 20 == 30 has no counterexample. Decimal('0.1') + Decimal('0.2') == Decimal('0.3') is also exactly True. Reach for the tolerance when you have inherited the floats; reach for integers when you get to choose.
Do not sprinkle isclose over every comparison in a codebase. Integers, strings and Decimals compare exactly and should be compared with ==; wrapping them in a tolerance hides real differences and invites the day two genuinely different values are declared the same. And when floats are compared inside a test rather than in production code, pytest.approx says the same thing with a better failure message. The tolerance belongs where the arithmetic is, not everywhere the arithmetic has been.
- Rewrite the whole function in integer pence, converting at the boundary, and see how many of these cases stop needing a tolerance at all.
- Add a currency with no subunit — the Japanese yen — and work out what PENNY / 2 should be then, and where that decision now has to live.
- Make the tolerance an argument with a default, then decide whether you have given callers a useful dial or a way to accept payments that are not paid.