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
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
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 ondouble,float, andHalf, and produce the IEEE 754 hexadecimal form. -
NumberStyles.HexFloatis a new style forParseandTryParsethat 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
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
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
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));
The output:
0x1.3333333333334p-2
0x1.6a09e667f3bcdp+0
0x1.5555555555555p-2
0x1p-1074
True
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, andHalfall formatted in my run;(Half)0.1printed0x1.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
The decimal point follows the culture.
1.5.ToString("x", new CultureInfo("de-DE"))printed0x1,8p+0, with a comma. No C compiler wants that. PassCultureInfo.InvariantCulturewhenever the text leaves your process.Plain
Parsedoes not read hex.double.Parse("0x1.8p1", CultureInfo.InvariantCulture)threwFormatException. Passing onlyNumberStyles.AllowHexSpecifierthrewArgumentException, because the exponent flag is required too. UseNumberStyles.HexFloat, which bundles the right flags.The
0xand the exponent are mandatory. WithHexFloat,TryParserejected both"1.8p1"(no prefix) and"0x1.8"(no exponent), and accepted"0x1.8p1"as3.A precision specifier rounds.
Math.PI.ToString("X4")printed0X1.9220P+1, and parsing it back did not equalMath.PI. For an exact round trip, use plain"X"or"x"with no number.Infinity and NaN are not hex. They print as the culture's symbols, so the invariant culture gave
InfinityandNaN. 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
- What's new in .NET 11 libraries: Floating-point hex formatting and parsing - Microsoft Learn
- Add support for floating-point hex formatting and parsing - dotnet/runtime PR 124139
- Standard numeric format strings - Microsoft Learn
- Math.BitIncrement method - .NET API reference
- Download .NET 11.0 - dotnet.microsoft.com
This article was written with the help of AI and checked against the sources above.
Top comments (0)