Number comparison in .NET: ==, Equals, and IEEE 754 edge cases

 
 
  • Gérald Barré

When people compare numbers, they usually expect a simple rule: same value means equal.

For floating-point numbers (Half, float, and double), there are actually two useful notions of equality in .NET:

  • IEEE comparison semantics, used by ==
  • equivalence semantics for .NET objects, used by Equals

At first sight this looks inconsistent. In practice, each one solves a different problem.

#Quick behavior matrix

For Half, double, and float, these are the important edge cases:

Case==EqualsCompareTo
NaN vs NaNfalsetrue0
+0.0 vs -0.0truetrue0
+Infinity vs +Infinitytruetrue0
-Infinity vs +Infinityfalsefalse< 0
NaN vs finite numberfalse (and != is true)falseNaN.CompareTo(x) < 0

== follows the floating-point comparison rules from IEEE 754 / IEC 60559. Equals is designed to support .NET equality contracts used by collections and dictionaries.

#Why NaN == NaN is false but NaN.Equals(NaN) is true

NaN means "Not a Number". It is a special IEEE 754 value used when an operation is undefined, for example a division by 0 (0.0 / 0.0).

IEEE 754 treats NaN as unordered in comparisons. That implies:

  • x == y is false if either side is NaN
  • x < y, x > y, x <= y, x >= y are all false if either side is NaN
  • x != y is true if either side is NaN

So this is expected:

C#
double x = double.NaN;

Console.WriteLine(x == x); // False
Console.WriteLine(x != x); // True
Console.WriteLine(x < 0);  // False
Console.WriteLine(x >= 0); // False

To test whether a value is NaN, use the type-specific APIs:

C#
Half h = Half.NaN;
float f = float.NaN;
double d = double.NaN;

Console.WriteLine(Half.IsNaN(h));   // True
Console.WriteLine(float.IsNaN(f));  // True
Console.WriteLine(double.IsNaN(d)); // True

But .NET also needs an equality notion that works for hash-based containers.

If Equals were IEEE-style for NaN, this would break reflexivity (x.Equals(x) should be true), and keys containing NaN would behave poorly in Dictionary / HashSet.

That is why Half.Equals, Double.Equals, and Single.Equals special-case NaN and return true when both values are NaN.

In runtime source, the implementation is effectively:

C#
public bool Equals(double obj)
{
    if (obj == m_value)
       return true;

    return IsNaN(obj) && IsNaN(m_value);
}

Half follows the same idea. In runtime source:

C#
public bool Equals(Half other)
{
    return _value == other._value
        || AreZero(this, other)
        || (IsNaN(this) && IsNaN(other));
}

#+0.0 and -0.0: equal, but not identical bits

IEEE 754 has signed zero. +0.0 and -0.0 compare equal, so both == and Equals return true:

C#
double pz = +0.0;
double nz = -0.0;

Console.WriteLine(pz == nz);        // True
Console.WriteLine(pz.Equals(nz));   // True

However, the sign still matters for some operations:

C#
Console.WriteLine(1.0 / +0.0);      // +Infinity
Console.WriteLine(1.0 / -0.0);      // -Infinity

So "equal" does not always mean "interchangeable in every expression".

Note: NaN values are also signed. Methods such as float.IsNegative return true for -NaN.

#CompareTo gives ordering, including NaN handling

Relational operators with NaN are intentionally awkward because NaN is unordered. For sorting, .NET exposes an ordering via CompareTo.

For Half, double, and float:

  • NaN compares equal to NaN
  • NaN compares less than non-NaN values

This behavior is explicitly encoded in CompareTo implementations in runtime source.

#Hash codes are normalized for NaN and signed zero

Another subtle but important point: GetHashCode() intentionally canonicalizes values.

In Half.GetHashCode, Double.GetHashCode, and Single.GetHashCode, .NET ensures:

  • all NaN bit patterns produce the same hash code
  • +0.0 and -0.0 produce the same hash code

This is required for consistency with Equals when values are used as keys.

#Precision edge case: values that look equal may not be equal

A separate source of confusion is representability, not NaN rules.

Many decimal values are not exactly representable in binary floating-point, so direct equality often fails:

C#
Console.WriteLine(0.1 + 0.2 == 0.3); // False

decimal solves this specific issue because it stores a base-10 scaled integer, not a base-2 fraction. Values such as 0.1m, 0.2m, and 0.3m are exactly representable, so this comparison is true:

C#
Console.WriteLine(0.1m + 0.2m == 0.3m); // True

decimal precision is about 28 to 29 significant decimal digits. Internally, it uses a 96-bit integer plus a scaling factor (0 to 28 decimal places) and a sign.

Important limits:

  • decimal is not arbitrary precision. It still has finite range and can overflow.
  • Operations that produce more than 28 to 29 significant digits are rounded.
  • decimal is about exact decimal representation, not IEEE floating-point behavior. It has no NaN or Infinity values.

For numeric algorithms, compare with tolerance (absolute + relative), not with Double.Epsilon:

C#
static bool NearlyEqual(double a, double b, double relTol = 1e-12, double absTol = 1e-15)
{
    if (a == b)
        return true; // handles infinities and signed zero equality

    if (double.IsNaN(a) || double.IsNaN(b))
        return false;

    if (double.IsInfinity(a) || double.IsInfinity(b))
        return false;

    double diff = Math.Abs(a - b);
    double scale = Math.Max(Math.Abs(a), Math.Abs(b));
    return diff <= Math.Max(absTol, relTol * scale);
}

#Practical guidance

Use this mental model:

  • Use == when you explicitly want IEEE comparison semantics.
  • Use Equals when you need .NET equality semantics (especially in collections).
  • Use Half.IsNaN, double.IsNaN, or float.IsNaN to test for NaN.
  • Use tolerance-based comparison for computed floating-point results.
  • Use decimal when you need exact base-10 fractions (for example, money), and when 28 to 29 significant digits are enough.

#Conclusion

Both == and Equals are correct.

They answer different questions:

  • ==: "Are these values equal under IEEE floating-point comparison rules?"
  • Equals: "Should these two .NET values be considered equal for object equality contracts?"

Once you separate those two intents, the edge cases around NaN, signed zero, and infinities become predictable.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?