Double to Decimal in .NET 11: Why 1.23 Is No Longer 1.23

Last updated: November 13, 2026 · Tested with .NET 11.0.0 (SDK 11.0.100) and .NET 10.0.12 (SDK 10.0.401), EF Core 11.0.0 / 10.0.13, SQL Server Express LocalDB, Windows 11

Short answer: in .NET 11, converting a double or float to decimal keeps the exact binary value instead of rounding it to 15 (or 7) significant digits. So (decimal)1.23 is now 1.2299999999999999822364316060, and (decimal)1.23 == 1.23m is false. Convert.ToDecimal(double), new decimal(double) and decimal.CreateChecked changed too. There’s no compatibility switch. If you need the old result, format the double with "G15" and parse it as a decimal. I checked that on one million values, and it matched .NET 10 every time.

Microsoft lists this as a behavioral change (Decimal and BigInteger floating-point conversions are correctly rounded). The new value is more accurate. The problem is the code that quietly depended on the old rounding. Below is everything I ran on both runtimes, and what changed in each case.

Where it showed up.NET 10.NET 11
(decimal)19.99 in a string or log19.9919.989999999999998436805981328
System.Text.Json{"Price":19.99}{"Price":19.989999999999998436805981328}
dict[0.2m] looked up with (decimal)0.2foundnot found
decimal.Round((decimal)1.005, 2, AwayFromZero)1.011.00
EF Core, SQL Server decimal(18,2), (decimal)2.6752.68 saved2.67 saved
EF Core, SQLite, Where(p => p.Price == 1.23m)2 rows1 row
const decimal x = (decimal)1.23; in a net10.0 app built with SDK 111.2299999999999999822364316060 (the compiler folds it, so the runtime doesn’t matter)
ToString("F2"), "C", "0.##"no change (19.99, $19.99, 19.99)

What Changed in .NET 11

A double can’t hold 1.23. It holds the nearest binary fraction, which is 1.229999999999999982236431605997495353221893310546875. Up to .NET 10, the conversion to decimal rounded that to 15 significant digits, so the error disappeared and you got 1.23. In .NET 11 the conversion rounds the exact value once, to the 28–29 digits a decimal can hold. The change came in through dotnet/runtime#130566 (decimal) and #130565 (BigInteger), both in 11.0 Preview 7.

The change also covers decimal to double/float/Half, and BigInteger to double/float. Those now round once instead of twice, or instead of truncating. I show those at the end. The double → decimal direction is the one most apps will notice.

How I Tested It

There’s one console project, Probe, with ten numbered sections, and one small project, Consts, for the compile-time constant case. A shared Directory.Build.props switches the target framework and the EF Core version: 10.0.13 for net10.0, 11.0.0 for net11.0. run.sh builds and runs net10.0 with the machine’s SDK 10.0.401 and net11.0 with a portable SDK 11.0.100, and then saves the output to results/. Every (decimal) conversion goes through a [MethodImpl(NoInlining)] identity method so the compiler can’t fold it into a constant:

[MethodImpl(MethodImplOptions.NoInlining)] static T Id<T>(T v) => v;

Without that method, the compiler can fold the cast into a constant at build time. That turned out to be a separate trap, covered in its own section below.

1. The Cast Itself

foreach (double d in new[] { 1.23, 0.1, 0.1 + 0.2, 19.99, 123.4567, 4.35 * 100, 1e-5, 12345678.9 })
{
    decimal m = (decimal)Id(d);
    Console.WriteLine($"{d.ToString("R", inv),-22} -> {m.ToString(inv),-32} scale {m.Scale,2}");
}
.NET 10:
Runtime: 10.0.12
1.23                   -> 1.23                             scale  2
0.1                    -> 0.1                              scale  1
0.30000000000000004    -> 0.3                              scale  1
19.99                  -> 19.99                            scale  2
123.4567               -> 123.4567                         scale  4
434.99999999999994     -> 435                              scale  0
1E-05                  -> 0.00001                          scale  5
12345678.9             -> 12345678.9                       scale  1
.NET 11:
Runtime: 11.0.0
1.23                   -> 1.2299999999999999822364316060   scale 28
0.1                    -> 0.1000000000000000055511151231   scale 28
0.30000000000000004    -> 0.3000000000000000444089209850   scale 28
19.99                  -> 19.989999999999998436805981328   scale 27
123.4567               -> 123.45669999999999788542481838   scale 26
434.99999999999994     -> 434.99999999999994315658113919   scale 26
1E-05                  -> 0.0000100000000000000008180305   scale 28
12345678.9             -> 12345678.900000000372529029846   scale 21

Look at 0.1 + 0.2 and 4.35 * 100. On .NET 10 the conversion also hid the error from double arithmetic, which is why (decimal)(0.1 + 0.2) printed 0.3. On .NET 11 the error shows up in the decimal. float is the same story with 7 digits:

.NET 10:
0.1f                   -> 0.1                              scale  1
1.1f                   -> 1.1                              scale  1
14.1f                  -> 14.1                             scale  1
.NET 11:
0.1f                   -> 0.100000001490116119384765625    scale 27
1.1f                   -> 1.10000002384185791015625        scale 23
14.1f                  -> 14.1000003814697265625           scale 19

Every way of doing the conversion changed. Parsing the double’s string did not:

.NET 11:
(decimal)1.23 == 1.23m        : False
new decimal(1.23)             : 1.2299999999999999822364316060
Convert.ToDecimal(1.23)       : 1.2299999999999999822364316060
decimal.CreateChecked(1.23)   : 1.2299999999999999822364316060
G29 (doc's example)           : 1.229999999999999982236431606
decimal.Parse(1.23.ToString()): 1.23

A small trap if you fix tests by pasting the new value: Microsoft’s breaking-change page shows the new result as 1.229999999999999982236431606. That’s what ToString("G29") prints. A plain ToString() prints 1.2299999999999999822364316060, with a trailing zero, because the scale is 28. They’re the same number, but as strings they don’t match.

2. Strings, Logs and JSON

Anything that prints a decimal without a format string now shows all the digits. That includes string interpolation, logging templates, ToString() in a view, and System.Text.Json:

Console.WriteLine(JsonSerializer.Serialize(new { Price = (decimal)Id(19.99), Tax = (decimal)Id(0.1 + 0.2) }));
.NET 10:
{"Price":19.99,"Tax":0.3}
.NET 11:
{"Price":19.989999999999998436805981328,"Tax":0.3000000000000000444089209850}

That JSON is a change to your API contract. A JavaScript client reads 19.989999999999998436805981328 back as the double 19.99, so a browser won’t notice. But snapshot tests, signatures over the payload, and strict clients in other languages all will. Format strings with a fixed number of decimals hide the change:

.NET 11:
interpolation : 19.989999999999998436805981328
F2            : 19.99
C (en-US)     : $19.99
0.##          : 19.99
N             : 19.990

3. Equality, Dictionary Keys and Tests

decimal equality is exact, so a converted value no longer equals the literal you meant. That breaks lookups too:

var vatByRate = new Dictionary<decimal, string> { [0.2m] = "standard", [0.05m] = "reduced" };
decimal rate = (decimal)Id(0.2);
Console.WriteLine($"key {rate.ToString(inv)} found: {vatByRate.ContainsKey(rate)}");
.NET 10:
key 0.2 found: True
.NET 11:
key 0.2000000000000000111022302463 found: False

The same thing breaks Assert.Equal(1.23m, (decimal)someDouble), switch statements on decimal constants, Distinct(), and GroupBy. Summing still comes out right once you round it. Here are 1,000 order lines, each a two-decimal price that arrives as a double:

.NET 11:
sum of (decimal)line      : 49345.150000000000080485618181
sum of decimal.Parse(line): 49345.15
equal: False; Round(sum,2) equal: True

4. Rounding Money: Midpoints Move

This one can change amounts. If you round a converted value to 2 decimals, most values come out the same as before. Values that look like an exact midpoint don’t. The double nearest to 1.005 is slightly below 1.005. .NET 10 rounded it to the 15-digit decimal 1.005 first, so AwayFromZero went up. .NET 11 keeps it below the midpoint, so it rounds down:

Console.WriteLine($"{d.ToString("R", inv),-6} cast {m.ToString(inv),-31} Round(2) {decimal.Round(m, 2).ToString(inv),-5} AwayFromZero {decimal.Round(m, 2, MidpointRounding.AwayFromZero).ToString(inv)}");
.NET 10:
1.005  cast 1.005                           Round(2) 1.00  AwayFromZero 1.01
2.675  cast 2.675                           Round(2) 2.68  AwayFromZero 2.68
1.015  cast 1.015                           Round(2) 1.02  AwayFromZero 1.02
0.285  cast 0.285                           Round(2) 0.28  AwayFromZero 0.29
1.125  cast 1.125                           Round(2) 1.12  AwayFromZero 1.13
.NET 11:
1.005  cast 1.0049999999999998934185896360  Round(2) 1.00  AwayFromZero 1.00
2.675  cast 2.6749999999999998223643160600  Round(2) 2.67  AwayFromZero 2.67
1.015  cast 1.0149999999999999023003738330  Round(2) 1.01  AwayFromZero 1.01
0.285  cast 0.2849999999999999755750934582  Round(2) 0.28  AwayFromZero 0.28
1.125  cast 1.125                           Round(2) 1.12  AwayFromZero 1.13

1.125 doesn’t move because it’s exact in binary. The others are one cent lower. You don’t even have to call Round yourself. SQL Server does the same rounding when EF Core writes to a decimal(18,2) column:

b.Entity<Product>().Property(p => p.Price).HasPrecision(18, 2);
.NET 10:
double 1.23   -> stored 1.23
double 19.99  -> stored 19.99
double 0.29   -> stored 0.29
double 1.005  -> stored 1.01
double 2.675  -> stored 2.68
.NET 11:
double 1.23   -> stored 1.23
double 19.99  -> stored 19.99
double 0.29   -> stored 0.29
double 1.005  -> stored 1.00
double 2.675  -> stored 2.67

The good news: ordinary prices like 1.23 and 19.99 still save correctly, because the column rounds the long tail away. Only the values that sit near a midpoint change. And they’re the ones a reconciliation report will catch weeks later.

Math.Round(double, digits) changed in .NET 11 as well (Math.Round and MathF.Round return correctly rounded results). On .NET 10, Math.Round(2.675, 2) returned 2.68. On .NET 11 it returns 2.67. My first sample generator used Math.Round(x, digits), and 486 of its 1,000,000 values came out different on the two runtimes before any decimal was involved:

Math.Round(2.675, 2) [double] = 2.67
486 of 1000000 Math.Round results differ (net10.0 runtime 10.0.12 vs net11.0 runtime 11.0.0)

5. EF Core and SQLite: The Long Value Is Saved as Text

SQLite has no decimal type, so EF Core saves decimal as TEXT. The converted value goes to disk with all its digits, and an equality query no longer finds that row:

.NET 10:
row Cable  stored '1.23' (text)
row Plug   stored '1.23' (text)
Where(Price == 1.23m) rows: 2
Distinct prices          : 1
.NET 11:
row Cable  stored '1.229999999999999982236431606' (text)
row Plug   stored '1.23' (text)
Where(Price == 1.23m) rows: 1
Distinct prices          : 2

The rows were saved by the .NET 11 app, so a database written on .NET 10 keeps its short values. The mix of old and new rows only starts once you upgrade, and it’s easy to miss.

6. The Gotcha: SDK 11 Changes Constants in .NET 10 Apps

The breaking-change page has one sentence about compile-time constants. I tested it, because it means this can reach you without moving to .NET 11:

const decimal Folded = (decimal)1.23;
decimal atRuntime = (decimal)Id(1.23);

I built the same net10.0 project twice and ran both builds on the 10.0.12 runtime:

SDK 10.0.401, net10.0, runtime 10.0.12:
const  (decimal)1.23 = 1.23
runtime(decimal)1.23 = 1.23
equal: True
SDK 11.0.100, net10.0, runtime 10.0.12:
const  (decimal)1.23 = 1.2299999999999999822364316060
runtime(decimal)1.23 = 1.23
equal: False

The compiler in SDK 11 folds (decimal)1.23 with the new algorithm and writes the result into the DLL. The 10.0 runtime still converts the old way. You end up with one app that has two answers for the same expression. A CI image or a dev machine that picks up SDK 11, with no global.json pin, is enough to cause it. If your code has (decimal) casts on double literals, change them to m literals now, whatever runtime you target.

7. The Other Direction

.NET 10:
(double)10000000000000.099609375m = 10000000000000.09765625
(double)(BigInteger)(long.MaxValue/2) = 4.6116860184273874E+18
.NET 11:
(double)10000000000000.099609375m = 10000000000000.099609375
(double)(BigInteger)(long.MaxValue/2) = 4.6116860184273879E+18

Both are exact fixes. The first number is exactly representable as a double, and .NET 10 still missed it. These only matter if you store or compare the bits, for example in hashes, golden files or binary protocols. (double)0.1m == 0.1 is True on both runtimes.

How to Fix It

1. Keep money in decimal from the start. Most of these doubles come from somewhere you can change: a double property on a DTO, a float column, JsonElement.GetDouble(), an Excel cell. Read them as decimal instead (GetDecimal(), decimal properties, decimal.Parse on the text). Then no conversion is left. Use m literals instead of (decimal)123.4567.

2. If the double is out of your control, round at the boundary to the precision you mean. decimal.Round(value, 2) fixes display, JSON, equality and dictionary keys for normal prices. It doesn’t fix midpoints, as section 4 showed.

3. If you need exactly what .NET 10 produced, use the G15 helper. This is the one from Microsoft’s breaking-change page (and "G7" for float):

static decimal LikePrevious(double value)
{
    Span<char> text = stackalloc char[32];
    value.TryFormat(text, out int length, "G15", CultureInfo.InvariantCulture);
    return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
}

I didn’t want to trust it on a few examples, so I converted the same 1,000,000 doubles (n / 10^k, so both runtimes get bit-identical inputs) on both runtimes and compared the strings:

inputs differing: 0
.NET 11 LikePrevious != .NET 10 cast: 0
.NET 11 cast != .NET 10 cast: 860620
.NET 10 cast != .NET 10 LikePrevious: 0

The plain cast changed for 86% of the values. The helper on .NET 11 matched .NET 10 for every one of them. It costs a format and a parse, but it doesn’t allocate. In a simple Stopwatch loop, not BenchmarkDotNet, 5 million conversions took about 1.6–2.2 s with the helper, against 0.3–1.0 s for the cast. That’s around 0.4 µs per value, which won’t matter outside a hot loop.

4. Find the conversions before your users do. Search for (decimal), Convert.ToDecimal(, new decimal( and decimal.Create, and check where each one gets its value. Then rerun the tests that compare decimals as strings, the JSON snapshots, and any rounding to cents. Update the expected values that only passed because of the old rounding. If the old value was the correct business answer, fix the conversion instead.

If you’re upgrading a whole app, this is one of the quieter changes. My .NET 11 upgrade post covers the ones that fail at build time or startup, and EF Core 11 breaking changes covers the data layer.

What I Didn’t Test

  • Linux, macOS and Arm64. The conversion is managed code, so I expect the same results there, but I only ran it on Windows x64.
  • Other databases and providers (PostgreSQL numeric, MySQL, Dapper with SqlClient directly). I tested SQL Server through EF Core only.
  • Half and BFloat16 conversions, which the breaking-change page also lists.
  • Newtonsoft.Json. It writes the decimal it gets, so I’d expect the same long output, but I didn’t run it.

Related: Breaking change: decimal and BigInteger conversions (Learn) · All .NET 11 breaking changes (Learn) · C# tutorials hub · Runtime async in .NET 11

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *