float('nan') as a dict key

2 min read source

float('nan') as a dict key

The gotcha

NaN can be a dict key — but you can’t look it up by another NaN, because NaN is not equal to anything (including itself). The dict still holds the entry; the lookup just doesn’t find it.

You can look it up via the same NaN object you inserted, because CPython short-circuits identity (is) before equality (==).

Minimal repro

nan = float('nan')

d = {nan: "x"}
print(d[nan])                # "x"           — same object, works via identity
print(d[float('nan')])       # KeyError      — different NaN object, equality fails

# Verify:
nan == nan                   # False         ← IEEE 754 says NaN != NaN
nan is nan                   # True          ← but it's the same object

Why it happens

IEEE 754 defines NaN as not equal to anything. nan == nan is False in any sane numeric system. But for dict lookup, CPython optimizes: it tries key is stored_key first (cheap pointer compare), and only falls back to key == stored_key if identity fails.

# Pseudocode for dict lookup
def lookup(d, key):
    bucket = hash(key) % table_size
    for stored_key, stored_val in d._bucket(bucket):
        if stored_key is key or stored_key == key:   # identity FIRST
            return stored_val
    raise KeyError(key)

Hash works because hash(float('nan')) is consistent — every NaN hashes the same. So all NaNs go to the same bucket. But the in-bucket comparison fails for different NaN instances.

Variants

import math
nan1 = float('nan')
nan2 = float('nan')
nan3 = math.nan

d = {nan1: 1}

d[nan1]    # 1                 same object → identity hit
d[nan2]    # KeyError           different object → equality fails
d[nan3]    # KeyError           different object
# Sets: same story
s = {nan1}
nan1 in s    # True
nan2 in s    # False
# In-list membership: also identity-first
[nan1].count(nan1)              # 1
[float('nan'), float('nan')]    # two distinct NaNs
[float('nan'), float('nan')].count(float('nan'))   # 0

What this affects

Pandas / NumPy treat NaN-as-missing carefully:

import pandas as pd
df = pd.DataFrame({"x": [1, float('nan')]})
df.x == df.x                    # [True, False]   ← NaN inequality leaks
df.x.equals(df.x)               # True            ← .equals treats NaN as equal
df.x.isna()                     # standard way to test for NaN

Set deduplication of NaN doesn’t work as expected:

{float('nan'), float('nan'), float('nan')}    # 3 distinct elements (each a different NaN)

But:

nan = float('nan')
{nan, nan, nan}                 # 1 element (same object)

How to detect NaN safely

Never use x == float('nan'). Use:

import math
math.isnan(x)                   # canonical test
x != x                          # works (only NaN is not equal to itself)
# pandas
import pandas as pd
pd.isna(x)

Interview angle

  • Q: “Can you use float('nan') as a dict key?” — yes. The entry goes in.
  • Q: “Can you look it up?” — only with the same object, because NaN ≠ NaN.
  • Follow-up: “Why does d[nan] work then?” — CPython checks identity (is) before equality. Same NaN object shortcuts the equality check.
  • Follow-up: “How do you test for NaN safely?” — math.isnan or x != x.

See 17_floats_and_equality.md, 23_eq_vs_hash.md.