I found the bug.

.NET does the following in clr\src\vm\comnumber.cpp :

DoubleToNumber(value, DOUBLE_PRECISION, &number); if (number.scale == (int) SCALE_NAN) { gc.refRetVal = gc.numfmt->sNaN; goto lExit; } if (number.scale == SCALE_INF) { gc.refRetVal = (number.sign? gc.numfmt->sNegativeInfinity: gc.numfmt->sPositiveInfinity); goto lExit; } NumberToDouble(&number, &dTest); if (dTest == value) { gc.refRetVal = NumberToString(&number, 'G', DOUBLE_PRECISION, gc.numfmt); goto lExit; } DoubleToNumber(value, 17, &number);

DoubleToNumber is pretty simple -- it just calls _ecvt , which is in the C runtime:

void DoubleToNumber(double value, int precision, NUMBER* number) { WRAPPER_CONTRACT _ASSERTE(number != NULL); number->precision = precision; if (((FPDOUBLE*)&value)->exp == 0x7FF) { number->scale = (((FPDOUBLE*)&value)->mantLo || ((FPDOUBLE*)&value)->mantHi) ? SCALE_NAN: SCALE_INF; number->sign = ((FPDOUBLE*)&value)->sign; number->digits[0] = 0; } else { char* src = _ecvt(value, precision, &number->scale, &number->sign); wchar* dst = number->digits; if (*src != '0') { while (*src) *dst++ = *src++; } *dst = 0; } }

It turns out that _ecvt returns the string 845512408225570 .

Notice the trailing zero? It turns out that makes all the difference!

When the zero is present, the result actually parses back to 0.84551240822557006 , which is your original number -- so it compares equal, and hence only 15 digits are returned.

However, if I truncate the string at that zero to 84551240822557 , then I get back 0.84551240822556994 , which is not your original number, and hence it would return 17 digits.

Proof: run the following 64-bit code (most of which I extracted from the Microsoft Shared Source CLI 2.0) in your debugger and examine v at the end of main :