Two facts. The second is the one that catches people who already know the first.
print(0.1 + 0.2) # -> 0.30000000000000004 print(0.1 + 0.2 == 0.3) # -> False # A relative tolerance scales with the numbers: print(abs(1000 - 1000.001) <= 0.01 * abs(1000)) # -> True print(abs(1 - 1.5) <= 0.01 * abs(1)) # -> False # ...and collapses to nothing at zero: print(0.01 * abs(0)) # -> 0.0 no tolerance at all
- Binary floats cannot hold most decimals exactly
0.1 and 0.2 are stored as the nearest values the format can manage, and the sum of those is not the nearest value to 0.3. The error is tiny and real. Any comparison of computed floats needs to allow for it.
- Relative tolerance means 'close given how big these are'
A millimetre matters when measuring a coin and not when measuring a road. Multiplying the tolerance by the size of the values is what makes one function work for both — but it means the allowance shrinks as the numbers do.
- At zero the relative allowance disappears
tolerance * abs(0) is 0, so nothing is ever close to zero except zero itself — and a computation that should give 0.0 often gives 1e-17 instead. That is why a second, absolute tolerance is needed as a floor, and why math.isclose takes both.
Compare the distance between the numbers against an allowance. The allowance depends on how big the numbers are — except when they are zero, when it has to come from somewhere else.
The distance is abs(a - b). The relative allowance is tolerance times the larger of the two magnitudes. The floor is a small absolute number, and the allowance you use is whichever of the two is bigger.
Guard the negative tolerance. Then: allowance = max(tolerance * max(abs(a), abs(b)), 1e-12) and return abs(a - b) <= allowance. Both max calls matter — one picks the larger magnitude, the other applies the floor.
def is_close(a, b, tolerance): if tolerance < 0: raise ValueError(...) relative = tolerance * ____(abs(a), abs(b)) allowance = ____(relative, 1e-12) return ____(a - b) <= allowance
ABSOLUTE_FLOOR = 1e-12
def is_close(a, b, tolerance):
if tolerance < 0:
raise ValueError(f"tolerance cannot be negative, got {tolerance}")
relative = tolerance * max(abs(a), abs(b))
allowance = max(relative, ABSOLUTE_FLOOR)
return abs(a - b) <= allowance
The relative part handles every scale; the absolute floor handles the one point where 'proportional' has no meaning. Three of the cases here are about zero, and a solution with only the relative term fails all three while passing everything else — which is why they are in the set.
Scaling by only one of the two makes the function asymmetric: is_close(a, b) could differ from is_close(b, a). Using the larger magnitude keeps it symmetric, which is what anyone reading the name will assume. math.isclose does exactly this.
math.isclose(a, b, rel_tol=..., abs_tol=...) is the same algorithm, in C, with the edge cases around infinity and NaN already handled — NaN is never close to anything, including itself, which this version gets wrong. Writing it once is how you learn what rel_tol and abs_tol mean; after that, import it.
Never use any of this for money — a tolerance is an admission that the value is approximate, and a balance is not. Use integer minor units or Decimal, as the bill-splitting exercise does. Be careful with tolerances in tests, too: one loose enough to hide a real regression is worse than a flaky comparison, so pick the smallest that makes the test stable rather than the first that makes it pass. And if you are comparing arrays, numpy.allclose does this elementwise and far faster.
- Handle NaN and infinity the way math.isclose does — NaN close to nothing, infinities close only to themselves — and see how many branches it adds.
- Let the caller supply the absolute floor instead of hard-coding it, and decide what a sensible default would be for measurements in millimetres.
- Write is_close_list for two sequences, and decide whether a length mismatch should return False or raise.