DEV Community

JKC
JKC

Posted on

Stop Guessing Which Bit Changed. .NET 11 Prints Doubles in Hex

TL;DR: In .NET 11, double, float, and Half accept the "X" format and NumberStyles.HexFloat. You get the exact bits as readable text, like 0x1.3333333333334p-2, and you can parse them straight back.

This ships in .NET 11, which went GA on 2026-11-10. Everything below was run on the .NET 11 RC 1 SDK.

The bug that prints fine

Every .NET developer meets this pair of lines sooner or later:

double sum = 0.1 + 0.2;
Console.WriteLine(sum);        // 0.30000000000000004
Console.WriteLine(sum == 0.3); // False
Enter fullscreen mode Exit fullscreen mode

So how far off is it? Decimal text cannot really tell you. It shows you a long tail of digits and leaves the counting to you.

The old way to see the truth was to dump the raw bits:

Console.WriteLine(BitConverter.DoubleToInt64Bits(sum).ToString("X16")); // 3FD3333333333334
Console.WriteLine(BitConverter.DoubleToInt64Bits(0.3).ToString("X16")); // 3FD3333333333333
Enter fullscreen mode Exit fullscreen mode

That works if you enjoy decoding sign, exponent, and mantissa by hand. It is technically readable, the way a tax form is technically readable.

What .NET 11 adds

Two things, one for each direction:

  • The "X" and "x" format specifiers now work on double, float, and Half, and produce the IEEE 754 hexadecimal form.
  • NumberStyles.HexFloat is a new style for Parse and TryParse that reads that form back.

The hex form is the same idea as C's printf("%a"): a hex mantissa, then p, then a power of two. It keeps every bit of the value, so the round trip is exact.

On the .NET 10 SDK, Math.PI.ToString("X") throws FormatException: Format specifier was invalid., and NumberStyles.HexFloat does not compile.

The minimal version

using System.Globalization;

double value = Math.PI;
string hex = value.ToString("X", CultureInfo.InvariantCulture);
Console.WriteLine(hex); // 0X1.921FB54442D18P+1

double back = double.Parse(hex, NumberStyles.HexFloat, CultureInfo.InvariantCulture);
Console.WriteLine(back == value); // True
Enter fullscreen mode Exit fullscreen mode

Uppercase "X" gives uppercase digits and prefix. Lowercase "x" gives 0x1.921fb54442d18p+1.

Back to 0.1 + 0.2

Now the mystery reads like a diff:

double sum = 0.1 + 0.2;
Console.WriteLine($"{sum:x}");  // 0x1.3333333333334p-2
Console.WriteLine($"{0.3:x}");  // 0x1.3333333333333p-2
Console.WriteLine(Math.BitIncrement(0.3) == sum); // True
Enter fullscreen mode Exit fullscreen mode

Same exponent, same mantissa, last hex digit off by one. That is exactly one step, which Math.BitIncrement confirms.

It also explains the classic float surprise in one glance:

Console.WriteLine($"{0.1f:x}"); // 0x1.99999ap-4
Console.WriteLine($"{0.1:x}");  // 0x1.999999999999ap-4
Enter fullscreen mode Exit fullscreen mode

Same number, very different amount of patience for the repeating 9s.

The realistic one: golden files that never drift

Snapshot tests for numeric code have a classic problem. Store results as decimal text with too few digits, and they stop round-tripping. Store them with "R" or "G17", and a reviewer cannot tell whether a change is one bit or a real regression.

Hex text keeps every bit and makes the bits readable:

using System.Globalization;

static string Save(IEnumerable<double> values) =>
    string.Join('\n', values.Select(v => v.ToString("x", CultureInfo.InvariantCulture)));

static double[] Load(string text) =>
    text.Split('\n')
        .Select(line => double.Parse(line, NumberStyles.HexFloat, CultureInfo.InvariantCulture))
        .ToArray();

double[] results = [0.1 + 0.2, Math.Sqrt(2), 1.0 / 3.0, double.Epsilon];

string golden = Save(results);
Console.WriteLine(golden);
Console.WriteLine(Load(golden).SequenceEqual(results));
Enter fullscreen mode Exit fullscreen mode

The output:

0x1.3333333333334p-2
0x1.6a09e667f3bcdp+0
0x1.5555555555555p-2
0x1p-1074
True
Enter fullscreen mode Exit fullscreen mode

Even double.Epsilon, the smallest positive subnormal, comes back as a tidy 0x1p-1074 and parses to the same value.

The same parser handles output from C or C++. double.Parse("0x1.921fb54442d18p+1", NumberStyles.HexFloat, CultureInfo.InvariantCulture) returned exactly Math.PI in my run.

Can I use it?

  • .NET 11 only. It is part of the base types, so there is no package to add.
  • Types: double, float, and Half all formatted in my run; (Half)0.1 printed 0x1.998p-4.
  • Docs: the "What's new in .NET 11 libraries" page covers it. The standard numeric format strings page still says "X" is for integral types only, so trust the what's-new page and the runtime PR over it for now.

Gotchas

  1. The decimal point follows the culture. 1.5.ToString("x", new CultureInfo("de-DE")) printed 0x1,8p+0, with a comma. No C compiler wants that. Pass CultureInfo.InvariantCulture whenever the text leaves your process.

  2. Plain Parse does not read hex. double.Parse("0x1.8p1", CultureInfo.InvariantCulture) threw FormatException. Passing only NumberStyles.AllowHexSpecifier threw ArgumentException, because the exponent flag is required too. Use NumberStyles.HexFloat, which bundles the right flags.

  3. The 0x and the exponent are mandatory. With HexFloat, TryParse rejected both "1.8p1" (no prefix) and "0x1.8" (no exponent), and accepted "0x1.8p1" as 3.

  4. A precision specifier rounds. Math.PI.ToString("X4") printed 0X1.9220P+1, and parsing it back did not equal Math.PI. For an exact round trip, use plain "X" or "x" with no number.

  5. Infinity and NaN are not hex. They print as the culture's symbols, so the invariant culture gave Infinity and NaN. Your parser for golden files needs to accept those words too.

Wrap-up

Decimal text tells you a double is wrong. Hex text tells you by how much. Open one flaky floating-point test, print both sides with "x", and see whether it was ever more than one bit.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (0)