Python's NaN-in-Set Trap: The Dedup That Passes Tests and Fails in Production

2026-06-12

This sensor pipeline deduplicates measurement readings while preserving order. The unit tests pass, code review approves it, and it ships. A week later, downstream consumers complain that error sentinels are appearing multiple times in the "deduplicated" output.

import json

def deduplicate(values):
    """Remove duplicate measurements, preserving first-seen order."""
    seen = set()
    unique = []
    for v in values:
        if v not in seen:
            seen.add(v)
            unique.append(v)
    return unique

# --- unit test (passes) ---
NAN = float('nan')
result = deduplicate([1.0, NAN, 2.0, NAN, 3.0])
assert result == [1.0, NAN, 2.0, 3.0]    # ✓ passes

# --- production code path ---
payload = '{"readings": [1.0, NaN, 2.0, NaN, 3.0]}'
readings = json.loads(payload)["readings"]
print(deduplicate(readings))
# Expected: [1.0, nan, 2.0, 3.0]
# Actual:   [1.0, nan, 2.0, nan, 3.0]

The Bug

Two facts collide here, both individually well-known, but together devastating:

  1. IEEE 754 says NaN != NaN. By design, no NaN compares equal to anything, including itself.
  2. CPython's set and dict membership tests use identity-then-equality. The membership check is effectively x is candidate or x == candidate. The identity shortcut exists for speed and to make weird objects (like NaN) at least seem well-behaved in collections.

In the unit test, every NAN in the list is literally the same Python object (id(NAN) is constant). When the loop hits the second NAN, the identity check v is seen_element returns True, and it's correctly skipped. The assertion also passes for the same reason — list equality is element-wise with the same identity-first rule.

In production, json.loads manufactures a fresh float('nan') object for every NaN token. Now each NaN in the list is a different object: identity fails, equality fails (NaN != NaN), and the "dedup" passes every NaN straight through. The deeper the pipeline, the worse it gets — every float(), numpy conversion, or arithmetic result that yields NaN produces a brand-new object.

The trap is especially nasty because the test looks like it covers the case. Reviewers see a NaN in the input and a deduplicated NaN in the expected output, and conclude the function handles NaN. It doesn't — it handles shared-identity NaN, which is an artifact of how the test was written, not a property of the data.

The Fix

Canonicalize NaN explicitly. Either drop it, or replace every NaN with a single sentinel before the set check:

import math

def deduplicate(values):
    seen = set()
    unique = []
    nan_seen = False
    for v in values:
        if isinstance(v, float) and math.isnan(v):
            if nan_seen:
                continue
            nan_seen = True
            unique.append(v)
        elif v not in seen:
            seen.add(v)
            unique.append(v)
    return unique

The same pattern bites dict keys, Counter, functools.lru_cache with float arguments, and anywhere "value equality" is silently doing identity equality for you. If your data can contain NaN, write a test where the NaNs come from different sources — parsing, arithmetic, conversion — not a shared module-level constant.

Key Takeaway: Python's set membership uses identity before equality, so NaN appears to deduplicate only when every NaN is the same object — a property your tests accidentally guarantee and production never does.

All newsletters