Skip to content

Float equality and NaN

Level: 201 · for Python programmers, especially ones coming from Rust or C

One line: 0.1 + 0.2 == 0.3 is False because both sides were rounded to binary and rounded differently; a tolerance only works if it scales with the numbers, which math.isclose does and sys.float_info.epsilon does not; and NaN is unequal to itself everywhere except inside a container, which checks identity first.

import math
0.1 + 0.2 == 0.3                   # False
math.isclose(0.1 + 0.2, 0.3)       # True

A float is an IEEE 754 double. No double equals one tenth, so the literal 0.1 holds the nearest one, and so do 0.2 and 0.3. Each literal was rounded on its own, and the sum of two rounded values is rounded once more. Whether that result lands on the same double as the rounded 0.3 is luck, and for this sum it misses by one step. == on floats is not broken. It compares two exact binary values and reports, correctly, that they differ.

Rust in Action walks through the same three problems in its section on comparing numbers: an equality test that fails, a tolerance test, and NaN. Python gives the same answers to all three, with three differences that are Python's own. There is no 32-bit float to make the first test pass by accident. Containers treat NaN differently from ==. And some operations raise an exception where Rust and C return a NaN or an infinity.

The run

Verified output of float_equality_and_nan_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. 0.1 + 0.2 IS NOT 0.3
     0.1 + 0.2            0.30000000000000004
     0.1 + 0.2 == 0.3     False
     0.5 + 0.25 == 0.75   True                  halves and quarters are exact in binary

                float.hex()             64 bits
     0.1 + 0.2  0x1.3333333333334p-2    3fd3333333333334
     0.3        0x1.3333333333333p-2    3fd3333333333333

     One step apart in the last hex digit. The exact values show why:
     0.1  is stored as 0.1000000000000000055511151231257827021181583404541015625
     0.2  is stored as 0.200000000000000011102230246251565404236316680908203125
     0.3  is stored as 0.299999999999999988897769753748434595763683319091796875
     0.1 and 0.2 are each stored a little high; 0.3 a little low.
     math.nextafter(0.3, math.inf) == 0.1 + 0.2   True

2. THE SAME SUMS IN 32 BITS
     Python has no 32-bit float. struct.pack('>f', x) rounds a double
     to the nearest one, which is enough to replay the arithmetic.

     question           left, 32 bits   right, 32 bits   in 32 bits   in 64 bits
     0.1 + 0.2 == 0.3   3e99999a        3e99999a         True         False
     0.1 + 0.6 == 0.7   3f333334        3f333333         False        True

     The first question is True in 32 bits and False in 64; the second
     is the other way round. Neither width is the accurate one: each
     rounds, and each gets lucky on different numbers. (For both pairs
     the double sum of the two 32-bit values is exact -- the program
     asserts it with Fraction -- so rounding that sum once to 32 bits
     is exactly what 32-bit addition does.)

3. A TOLERANCE HAS TO BE RELATIVE
     0.2 - (0.1 + 0.1)   is 0.0 in 64 bits, and 0.0 in 32
     A difference of exactly zero passes any tolerance, and == too.
     To test a tolerance, the two sides have to differ:

     sys.float_info.epsilon                      2.220446049250313e-16    the gap between 1.0 and the next double
     math.ulp(2000.3)                            2.2737367544323206e-13   the gap near 2000
     math.ulp(2000.3) / sys.float_info.epsilon   1024.0

     a = 1000.1 + 1000.2
     a                                           2000.3000000000002
     a == 2000.3                                 False
     a - 2000.3 == math.ulp(2000.3)              True                 one gap apart: the next double up
     abs(a - 2000.3) <= sys.float_info.epsilon   False                no two doubles near 2000 are that close
     math.isclose(a, 2000.3)                     True                 rel_tol=1e-09 scales with the numbers
     math.isclose(1e-20, 0.0)                    False                abs_tol defaults to 0.0
     math.isclose(1e-20, 0.0, abs_tol=1e-12)     True                 near zero, say what close means

4. EQUALITY IS NOT THE BITS
     0.0 == -0.0   True   and the bits are 0000000000000000 and 8000000000000000
     nan = float('nan')

     nan == nan                False   every comparison with NaN is False...
     nan != nan                True    ...except !=, which is its negation
     nan < 1.0 or nan >= 1.0   False   not less, not greater-or-equal
     math.isnan(nan)           True    the test that works

     Containers ask 'is it the same object?' before they ask ==:
     [nan] == [nan]                          True    one object, twice: equal
     nan in [nan]                            True
     {nan: 'x'}.get(nan)                     'x'
     [float('nan')] == [float('nan')]        False   two objects: == decides, and says no
     {float('nan'): 'x'}.get(float('nan'))   None
     math.nan is math.nan                    True    a module constant is one object...
     [math.nan] == [math.nan]                True    ...so this list compares equal

5. WITH A NaN IN IT, THE ORDER OF THE ARGUMENTS DECIDES
     max(nan, 1.0)                  nan                    asks 1.0 > nan: False, so nan stays
     max(1.0, nan)                  1.0                    asks nan > 1.0: False, so 1.0 stays
     min(nan, 1.0)                  nan
     min(1.0, nan)                  1.0
     sorted([3.0, nan, 1.0, 2.0])   [3.0, nan, 1.0, 2.0]   not sorted, and no error

6. SOMETIMES PYTHON RAISES WHERE IEEE 754 GIVES A VALUE
     math.sqrt(-42.0)   ValueError          IEEE 754 says NaN
     1.0 / 0.0          ZeroDivisionError   IEEE 754 says inf
     1.0 % 0.0          ZeroDivisionError   C's fmod says NaN
     math.log(0.0)      ValueError          IEEE 754 says -inf
     1e308 ** 2         OverflowError       C's pow says inf

     ...and sometimes it hands the value back:
     1e308 * 1e308               inf     the same number as 1e308 ** 2, and no error
     1e308 * 10                  inf
     math.inf - math.inf         nan
     math.inf * 0                nan
     math.isfinite(1e308 * 10)   False   the check to make before trusting a result

     (-42.0) ** 0.5    a complex, imaginary part 6.480741
     -42.0 ** 0.5      -6.480741   ** binds tighter than the minus: -(42.0 ** 0.5)

What the run shows

Section 1: the sum misses by one double. float.hex() is the readable way to see the bits: 0x1.3333333333334p-2 against 0x1.3333333333333p-2, the same mantissa except the last hex digit. The 64-bit patterns from struct.pack('>d', ...) are 3fd3333333333334 and 3fd3333333333333, the bits Rust prints with to_bits(). Decimal(x) shows the exact value a float holds, and it explains the miss. 0.1 and 0.2 were each stored slightly high, 0.3 slightly low, and the errors did not cancel. math.nextafter(0.3, math.inf) confirms that the sum is the very next double above 0.3. 0.5 + 0.25 == 0.75 is True, because halves and quarters are stored exactly. The trouble is decimal fractions, not floating point in general.

Section 2: 32 bits changes the luck, not the accuracy. The book's version of the test passes for f32. Python has no 32-bit float, but struct.pack('>f', x) rounds a double to the nearest one, which is enough to replay it. 0.1f + 0.2f and 0.3f are both 3e99999a, so the 32-bit test passes. 0.1 + 0.6 == 0.7 does the opposite: True in 64 bits, False in 32 (3f333334 against 3f333333). Neither width is more trustworthy. Each rounds, and each is lucky on different sums. The replay rests on one condition, which the program asserts rather than assumes: the double sum of the two 32-bit operands is exact, so rounding it once to 32 bits is 32-bit addition. Fraction confirms that for both pairs. It is not true of every pair of 32-bit floats. Two operands of very different magnitude have an exact sum that needs more than 53 bits.

Section 3: a tolerance has to scale. The book's tolerance example subtracts 0.1 + 0.1 from 0.2, and that difference is exactly 0.0 in both widths. Zero passes any tolerance, and it passes == too, so the example never actually tests the tolerance. The pair 1000.1 + 1000.2 and 2000.3 does. sys.float_info.epsilon is 2.220446049250313e-16, the gap between 1.0 and the next double, and it means nothing at 2000. There the gap, math.ulp(2000.3), is 1024 times wider. The sum is exactly one gap from 2000.3, and an absolute test against epsilon rejects it, because no two different doubles near 2000 are that close. The test has become == with extra steps. math.isclose uses rel_tol=1e-09 by default, relative to the larger of the two magnitudes, and accepts. Its other default, abs_tol=0.0, is the trap in the opposite direction. Relative to zero, every tolerance is zero, so math.isclose(1e-20, 0.0) is False until you say what "close to zero" means for your data.

Section 4: equality is not the bits, in either direction. 0.0 == -0.0 is True, though the bit patterns differ. A NaN goes the other way: the same object, with the same bits, is unequal to itself. That is the IEEE 754 rule, and Python's language reference states it for float('NaN') and Decimal('NaN'). Every ordered comparison with NaN is False, and != is True because it is the negation of ==. math.isnan is the test that works. The container rows are Python's own. list.__eq__, in and a dict lookup each check is before ==, so a NaN compared with itself inside a container is found and equal. Two NaN objects, as two float('nan') calls make, are compared with == and are not. math.nan is a single module-level object, so [math.nan] == [math.nan] is True, which makes a test that passes for a reason nobody intended.

Section 5: with a NaN in the arguments, their order decides. max() keeps its first argument and replaces it only when a later one compares greater. Every comparison with NaN is False, so a NaN in first place is never replaced, and a NaN anywhere else is never chosen. max(nan, 1.0) is nan and max(1.0, nan) is 1.0. sorted() has the same problem across a whole list and reports nothing: [3.0, nan, 1.0, 2.0] comes back unchanged. The wrong answer is not even stable across releases. The same list with the NaN moved to the front sorts differently before and after 3.13, so that row is dated below rather than kept in the answer key.

Measured 2026-09-16 in python:3.11/3.12/3.13/3.14-slim; not verbatim program output
                                   3.11                  3.12                  3.13                  3.14
  sorted([nan, 3.0, 1.0, 2.0])     [nan, 1.0, 2.0, 3.0]  [nan, 1.0, 2.0, 3.0]  [1.0, nan, 2.0, 3.0]  [1.0, nan, 2.0, 3.0]
  sum([0.1] * 10) == 1.0           False                 True                  True                  True

The second row is float equality changing under your feet. 3.12 switched sum() of floats to compensated summation, so an equality test that failed on 3.11 passes on 3.12, with no change to your code.

Section 6: Python raises where IEEE 754 returns a value, but only in some places. math.sqrt(-42.0), 1.0 / 0.0, 1.0 % 0.0, math.log(0.0) and 1e308 ** 2 all raise, where the standard (and C, and Rust) return NaN, an infinity or -inf. The pattern is where Python chose to check. Division, %, ** and the math functions check their operands or their result. Plain *, + and - do not, so 1e308 * 1e308 is inf with no error: the same number that 1e308 ** 2 refused to produce. math.inf - math.inf is nan, silently. So a NaN or an infinity can still reach your data. It arrives through the operations that do not check. Testing values with math.isfinite and math.isnan is the Python spelling of the defence the book recommends. The last two lines are a precedence trap rather than a float rule. (-42.0) ** 0.5 is a complex number, because ** with a negative base and a fractional exponent returns one in Python 3. -42.0 ** 0.5 is -(42.0 ** 0.5), because ** binds tighter than unary minus, so it is a perfectly ordinary -6.480741.

If you are coming from Rust

Rust has the same doubles and makes different choices around them. It has f32, so the book's 32-bit test is a real test that passes, not a replay. It never raises for floating-point arithmetic. And it keeps NaN out of places that need a total order: f64 implements PartialEq and PartialOrd but not Eq or Ord, so sort() on a Vec<f64> does not compile, and neither does insert on a HashSet<f64>. Python accepts a NaN in sorted(), in a set and as a dict key, and gives back whatever falls out.

Measured 2026-09-16 with rustc 1.98.0, beside Python 3.14.7; not verbatim program output
  Python                                           Rust
  math.sqrt(-42.0)       ValueError                (-42.0_f64).sqrt()         NaN
  1.0 / 0.0              ZeroDivisionError         1.0_f64 / 0.0              inf
  -42.0 ** 0.5           -6.48074069840786         -42.0_f64.sqrt()           -6.48074069840786
  max(nan, 1.0)          nan                       f64::NAN.max(1.0)          1.0
  max(1.0, nan)          1.0                       1.0_f64.max(f64::NAN)      1.0
  sorted([3.0, nan, 1.0, 2.0])                     v.sort()                   error[E0277]: f64: Ord is not satisfied
                         [3.0, nan, 1.0, 2.0]      v.sort_by(|a, b| a.total_cmp(b))
                                                                              [1.0, 2.0, 3.0, NaN]
  0.2 - (0.1 + 0.1)      0.0                       (0.2_f32 - (0.1_f32 + 0.1_f32)).abs()   0.0

The third row is the same trap in both languages. A method call binds tighter than unary minus in Rust, as ** does in Python, so neither line takes the square root of a negative number. f64::max returns the non-NaN operand whichever side it is on, which is the order-independence Python's max() lacks. The Rust library covers the bits in What a float stores ↗, and goes through the book's section claim by claim in Comparing numbers, claims checked ↗.

If you are coming from C

C returns the IEEE 754 value and records the event: sqrt(-42.0) is NaN and sets the FE_INVALID flag, and 1.0 / 0.0 is inf and sets FE_DIVBYZERO. The flags are sticky and nothing traps by default, so a program that never checks fetestexcept() never knows. The C library shows that in A float exception is a flag ↗. Python took the other side for some operations only. The checked operations in section 6 turn the event into an exception you cannot ignore. The unchecked ones behave like C with the flags thrown away, because Python gives you no way to read them. The fpectl module that once tried was removed in 3.7.

If you are coming from ABAP

ABAP's f type is the same binary double, so 0.1 + 0.2 compared with 0.3 in f fields has the same problem. The usual ABAP answer is not a tolerance at all but a different type: p (packed decimal with fixed decimal places) and decfloat16 / decfloat34 store 0.1 exactly, which is what Decimal does here, and why SAP stores amounts as packed numbers rather than f. ABAP also leans toward Python's side on errors. Division by zero raises CX_SY_ZERODIVIDE rather than returning an infinity, overflow raises CX_SY_ARITHMETIC_OVERFLOW, and a square root of a negative number raises rather than returning NaN. ABAP documents one special case that neither Python nor IEEE 754 has: a zero dividend divided by zero gives 0. Check the arithmetic and exception rules for your release before relying on these names. (Not machine-checked — CI cannot run ABAP.)

Try it

  1. Search your code for == 0.0, != 0.0, and == against a float literal. For each hit, decide whether the value is computed or assigned. An assigned 0.0 compares fine. A computed one needs math.isclose with an abs_tol you choose, and choosing it means knowing your data's units.
  2. Find a tolerance constant in a project of yours, like 1e-9, EPS or sys.float_info.epsilon. Print math.ulp() of the largest value that tolerance is ever applied to. If the gap is wider than the tolerance, the check is == in disguise, as in section 3.
  3. Take a column of measurements with gaps in it, from a CSV where empty cells become float('nan'). Compute max() over it in file order and in reversed order. If the answers differ, find the row that did it.
  4. Put math.isfinite on the boundary of one pipeline you run. Assert it on every value that leaves a calculation, run the pipeline on real input, and count what trips it. Section 6 says the inf and nan values reached you through *, + or -, so look there first.
  5. Run a test suite of yours under python:3.11-slim and python:3.12-slim. If a float assertion changes result between them, look for a sum() of floats first: that is the change dated in section 5.

Practice

Eight one-liners, and the one that depends on the order. Write down each result (a value, or the exception it raises) before running anything. import math first.

  1. 0.5 + 0.25 == 0.75
  2. 0.1 * 3 == 0.3
  3. math.isclose(0.1 * 3, 0.3)
  4. math.isclose(1e-20, 0.0)
  5. float('nan') in [float('nan')]
  6. min(float('nan'), 0.0)
  7. 2.0 ** 1024
  8. -0.0 == 0.0

Then: one line would give a different answer if its two arguments were swapped, though what it asks should not depend on their order. Which one? And on which two lines was == the right test?

Answers

Verified output of float_equality_and_nan_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.

     expression                       result          note
     ------------------------------------------------------------------------------------------
     0.5 + 0.25 == 0.75               True            halves and quarters are exact in binary
     0.1 * 3 == 0.3                   False           0.30000000000000004 is the next double up
     math.isclose(0.1 * 3, 0.3)       True            one gap apart, far inside rel_tol=1e-09
     math.isclose(1e-20, 0.0)         False           relative to zero is zero; abs_tol is 0.0
     float('nan') in [float('nan')]   False           two objects, so == decides, and says no
     min(float('nan'), 0.0)           nan             asks 0.0 < nan: False, so nan stays
     2.0 ** 1024                      OverflowError   ** checks for overflow; * does not
     -0.0 == 0.0                      True            different bits, equal numbers

     THE ONE THAT DEPENDS ON THE ORDER
     Line 6. Swapped, min(0.0, float('nan')) is 0.0. min() only
     replaces its answer when a later argument compares less, and
     every comparison against a NaN is False, so whichever argument
     comes first is kept. A minimum should not depend on the order
     it was asked in, and with a NaN in it, it does.

     THE TWO WHERE == WAS THE RIGHT TEST
     Lines 1 and 8. 0.5, 0.25, 0.75 and both zeros are stored exactly,
     so == compares the numbers you wrote. Line 2 is the same shape
     as line 1 and is False, because 0.1 and 0.3 are not stored
     exactly -- which is the whole reason line 3 exists.

See also