skip to content

IEEE-754 specifies that a NaN floating-point value is not equal to itself. Which law of an equivalence relation does that break, and how do different languages' hash containers and sorting routines cope with a value that is not equal to itself?

level: seniorimportance: should knowfreq 28%

answer

  1. Reflexivity is what makes equality a partition
  2. Store succeeds, lookup fails — silent unbounded growth
  3. JS: strict equality vs SameValueZero vs Object.is
  4. Rust: PartialEq without Eq, so no float keys
  5. Negative zero diverges in the opposite direction

basics

~20 s

It breaks reflexivity, the base law. Languages cope differently: JavaScript keys Map and Set with SameValueZero so NaN works as a key; Go accepts a NaN map key you can never read back; Rust withholds the Eq trait, so a float cannot key a hash map at all.

solid answer

~60 s

Reflexivity — every value equals itself — is what lets equality partition a value space into classes. NaN belongs to no class, not even its own, so any container that locates an entry and then confirms it with the equality operator can store something it can never find. Languages take one of three exits: - **Patch the container.** JavaScript's `===` follows IEEE, but `Map`, `Set` and array containment use SameValueZero, which declares NaN equal to itself and positive zero equal to negative zero. `Object.is` is a *third* relation: NaN equal, the two zeros distinct. Which relation you get depends on which API you call. - **Restore reflexivity by identity.** Python's containers test `x is y or x == y`, so the *same* NaN object is retrievable as a key while a freshly built one is not. - **Refuse the type.** Rust splits PartialEq from Eq; floats implement only the former, and hash-map keys require Eq, so the compiler rejects the container outright. Go does none of these: a NaN key inserts and is unreachable forever.

go deeper

for a junior

Know that NaN is not equal to itself and that this makes floats unsafe as container keys or sort inputs.

for a middle

Name reflexivity as the broken law and describe the store-succeeds-lookup-fails failure mode concretely.

for a senior

Contrast at least two coping strategies by name — patching the container versus withholding the type-system capability — and extend the point to signed zero.

for a principal

Treat it as a lesson about where invariants should be enforced: a language that patches containers multiplies its equality relations, while one that encodes the law in types pushes the decision to the API boundary where it is reviewable.

## The law and why it matters An equivalence relation must be reflexive, symmetric and transitive. Reflexivity is the quiet one: it is what guarantees that every value falls into exactly one equality class, and therefore that the relation *partitions* the value space. Symmetry and transitivity shape the classes; reflexivity is what guarantees they exist and cover everything. IEEE-754 deliberately violates it. NaN means "there is no value here", and every ordered comparison involving it is unordered, so equal-to, less-than and greater-than are all false and not-equal is true. A consequence worth internalising: with NaN present, less-than-or-equal is no longer the negation of greater-than, so an ordering built by negating a comparison silently becomes inconsistent. The downstream damage is structural rather than cosmetic. A hash container finds a candidate slot from the hash and then *confirms* the match with the equality relation. If a value is not equal to itself, the confirmation always fails: insertion succeeds, lookup fails, deletion fails, and repeated insertion of the same value keeps adding entries. A sorted container assumes a total (or at least strict weak) order; NaN makes the order partial, and a comparison-based sort given a partial order can do considerably worse than return the wrong sequence. ## Four strategies, named **Patch the container with a second relation.** JavaScript ships three equality relations at once. Strict equality follows IEEE, so NaN is not equal to NaN. `Map` and `Set` keys, and array containment, use SameValueZero, which declares NaN equal to itself so it can serve as a key, while still treating positive and negative zero as the same key. `Object.is` is the third: NaN equal to itself, but the two zeros distinct. The programmer must know which relation each API uses; nothing in the syntax says. **Patch it in the boxed type.** Several JVM- and CLR-family languages let the primitive comparison operator follow IEEE while the boxed type's equality method declares NaN equal to NaN and orders negative zero strictly below positive zero — precisely so hashed and sorted containers see a total order. The result is a wrapper type whose equality method deliberately disagrees with its own comparison operator on exactly two inputs. **Restore reflexivity with identity.** Python's containers test identity before equality: the same NaN object is found in a list, a set or as a dict key, while an equal-valued but distinct NaN object is not. This makes membership depend on object provenance, which is reproducible but surprising: building the key twice gives two different answers. **Refuse the type.** Rust encodes the violation in the type system instead of patching containers. Floats implement PartialEq (a symmetric, transitive, *not necessarily reflexive* relation) but not Eq, and the hash-map key bound requires Eq — so `HashMap<f64, _>` does not compile. Ordering follows the same split: floats have PartialOrd but not Ord, so the default sort is unavailable and you must opt into a partial comparison or an explicit IEEE total order. The programmer is forced to state which relation they meant. **Do nothing.** Go's `==` on floats follows IEEE and its maps have no user hook. A NaN key can be inserted; every subsequent lookup and delete misses; every insert adds another unreachable entry. It is a documented way to grow a map without bound, and clearing the map is the only removal. C++ adds a further wrinkle: ordered containers require a strict weak ordering, and the built-in less-than on floats with NaN present does not provide one. Violating that requirement is undefined behaviour, not merely a wrong result. C++20 makes the distinction explicit by giving the three-way comparison on floating point the *partial ordering* category, distinct from weak and strong. ## Negative zero, the second divergence The same fault line runs through positive and negative zero, in the opposite direction. IEEE and SameValueZero say the two zeros are equal; `Object.is` and the boxed-type equality methods say they differ. So a de-duplication pass over a set of measurements can return a different cardinality depending only on which relation the container happens to use — with no NaN anywhere in the data. ## The design lesson Two philosophies, and it is worth saying which you prefer and why. Patching the container keeps floats usable everywhere at the price of a language now having several equality relations that the programmer must track per API. Refusing the type keeps exactly one relation, at the price of louder code and explicit wrapper types when you genuinely want float keys. Either way, the practical rule for production data is the same: never key a container on a raw float. Canonicalise at the boundary — an integer, a fixed-point decimal, or a string with a fixed format — and reject or normalise NaN and signed zero there, where the decision is visible.

  • Rust's split between PartialEq and Eq carries no extra code — Eq has no methods. What is it for?
    It is a machine-checked declaration of which laws hold. PartialEq promises symmetry and transitivity only; Eq additionally promises reflexivity, and API bounds that need a genuine partition — hash-map keys, deduplication — require Eq. Because floats implement only PartialEq, the misuse is rejected at compile time rather than discovered as a missing entry in production.
  • Your service deduplicates records keyed on a floating-point measurement and the deduplicated set is occasionally larger than the input. What would you check first?
    Whether NaN values reach the key. In a container that follows IEEE equality, each NaN insert is a fresh entry, so duplicates multiply rather than collapse. If the runtime instead uses a NaN-equal relation, check signed zero, which can split or merge classes depending on the relation. The fix is to canonicalise the key at ingestion — reject NaN and normalise negative zero — rather than to patch the container.

saying these in an interview costs you the question

  • Saying NaN is 'just a weird value' rather than naming reflexivity
  • Assuming every container in a language uses the language's equality operator
  • Believing an equality operator and a boxed type's equality method must agree
  • Thinking a NaN key can be deleted from a map by passing the same NaN
  • Assuming sorting with NaN merely yields a wrong order rather than violating the sort's contract

context