Skip to content
  • English Deutsch

benjamin-abt.com

Performance is a feature | Cloud - AI - Azure - .NET - Sustainability

  • Home
  • Blog
  • Portfolio
    • Overview
    • Workshops & Training
    • Publications
  • Contact
  • Home
  • Blog

The Big Decimal Bugfix with .NET 11

The Big Decimal Bugfix with .NET 11
November 2, 2026 11 min read BEN ABT
.NET .NET 11 Csharp Breaking Changes Testing

For more than two decades, (decimal)1.23 returned 1.23m. It looked correct, it passed every test and it was a polite lie.

The double literal 1.23 has never been 1.23. Its exact binary value is 1.229999999999999982236431605997.... The old conversion simply rounded every double to 15 significant digits before it created the decimal, which happened to recover the text that a human had typed. That made broken code look correct.

.NET 11 fixes this - and several related conversions that were actually wrong in the other direction:

  • double and float to decimal keep every digit that decimal can represent;
  • decimal to double, float, Half and BFloat16 round once instead of twice;
  • BigInteger to double, float, Half and BFloat16 round to nearest instead of truncating.

All of these conversions now follow the rule that floating-point code is expected to follow: compute the exact value, then round exactly once to the destination type.

This is a real bugfix. It is also a behavioral breaking change without a compatibility switch, and the first part will feel like a regression in code that uses double as a transport for money.

Release status: The change was introduced in .NET 11 Preview 7. All outputs in this article were verified with .NET 10.0.14 and .NET 11 RC 3 (SDK 11.0.100-rc.3) on the same machine. The official description is Decimal and BigInteger floating-point conversions are correctly rounded .

The differences at a glance

The values are passed through a non-inlined method so the runtime performs the conversion, not the compiler. Why that matters follows further below.

Expression.NET 10.NET 11
(decimal)1.231.231.2299999999999999822364316060
(decimal)1.23 == 1.23mTrueFalse
(decimal)0.10.10.1000000000000000055511151231
(decimal)(0.1 + 0.2)0.30.3000000000000000444089209850
(decimal)14.1f14.114.1000003814697265625
(decimal)19.9919.9919.989999999999998436805981328
Math.Round((decimal)2.675, 2)2.682.67
Math.Round((decimal)1.005, 2, MidpointRounding.AwayFromZero)1.011.00
(double)10000000000000.099609375m10000000000000.0976562510000000000000.099609375
(Half)2049.0001m20482050
(double)(BigInteger)(long.MaxValue / 2)4.6116860184273874E+184.6116860184273879E+18

The same applies to every path into the conversion: new decimal(double), Convert.ToDecimal(double), decimal.CreateChecked, CreateSaturating, CreateTruncating, IConvertible and the generic math interfaces.

Parsing is not affected. decimal.Parse("2.675") and a decimal property deserialized by System.Text.Json still produce exactly 2.675, because no binary floating-point value is involved.

double to decimal: the friendly lie is gone

decimal stores a 96-bit integer and a decimal scale. It can hold 28 to 29 significant digits. Before .NET 11, a conversion from double used only 15 of them, and a conversion from float only 7.

C#
1using System.Globalization;
2
3double value = 1.23;
4decimal converted = (decimal)value;
5
6Console.WriteLine(converted.ToString(CultureInfo.InvariantCulture));
7Console.WriteLine(converted == 1.23m);

.NET 10:

11.23
2True

.NET 11:

11.2299999999999999822364316060
2False

The .NET 11 result is the closer one. The double really contains 1.229999999999999982236431605997495353221893310546875, and the new decimal is the nearest representable value to that number.

The old result was a different number. It was close to the original, and it matched the literal in the source code, but it was produced by throwing away information the destination type could represent. For a literal like 1.23 that is convenient. For a value that came out of a calculation, it was arbitrary: the conversion quietly decided that the 16th digit was noise.

Three things now become visible:

  1. Equality changes. (decimal)someDouble == someDecimalLiteral is now usually false.
  2. Formatting changes. The default decimal.ToString() prints the full scale. A log line, a CSV export or a UI label that showed 19.99 now shows 19.989999999999998436805981328. With "G29" the trailing zero of the 1.23 example disappears, but the digits remain.
  3. Snapshots change. Approval tests, golden files and serialized expected values that stored the old string representation fail.

None of these is a new bug. Each one is an old precision problem that the conversion used to hide.

Where it hurts: money that took a detour through double

The interesting case is not 1.23. It is rounding.

C#
1double price = 2.675;           // read from an API, a spreadsheet or a float column
2decimal amount = (decimal)price;
3
4decimal rounded = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
.NET 10.NET 11
amount2.6752.6749999999999998223643160600
rounded2.682.67

The commercial rule round half away from zero never applies in .NET 11, because the value is no longer half. It is slightly below half, and it always was. 2.675 as a double is 2.67499999999999982236431605997....

So which result is correct? For the value the program actually holds, 2.67 is correct. For the value a human meant, 2.68 is correct. The old conversion produced the second answer by accident, and it only worked because the original value had fewer than 15 significant digits.

The same pattern appears whenever a decimal quantity passes through a binary floating-point type on its way into decimal:

C#
1public sealed record PriceDto(double Amount);
2
3PriceDto dto = JsonSerializer.Deserialize<PriceDto>("""{ "Amount": 2.675 }""")!;
4decimal amount = (decimal)dto.Amount; // 2.6749999999999998223643160600 in .NET 11

A DTO with a double property, an Excel or CSV reader returning double, a database column of type float or real, a JavaScript client sending numbers that are mapped to double - the information was lost the moment the value became a double. .NET 11 no longer pretends otherwise.

The correct fix is to keep the value decimal from the start:

C#
1public sealed record PriceDto(decimal Amount);
2
3PriceDto dto = JsonSerializer.Deserialize<PriceDto>("""{ "Amount": 2.675 }""")!;
4decimal amount = dto.Amount; // 2.675 on every runtime

System.Text.Json parses the JSON number text directly into decimal. No binary value, no rounding, no surprise.

decimal to double: round once, not twice

The opposite direction was wrong in a less visible way. Converting a decimal to double could round more than once on the way, and two roundings can land on a different value than one rounding of the exact number.

C#
1using System.Globalization;
2
3decimal value = 10000000000000.099609375m;
4double converted = (double)value;
5
6Console.WriteLine(converted.ToString("G99", CultureInfo.InvariantCulture));

.NET 10:

10000000000000.09765625

.NET 11:

10000000000000.099609375

10000000000000.099609375 is exactly representable as a double: the spacing between neighboring double values at this magnitude is 2^-9 = 0.001953125, and .099609375 is 51/512. The old conversion returned the neighbor 50/512, although there was nothing to round at all.

The effect is easier to see with a small destination type. Half has an 11-bit significand. Between 2048 and 4096 the representable values are 2048, 2050, 2052 and so on.

C#
1decimal value = 2049.0001m;
2Half converted = (Half)value;

The nearest Half to 2049.0001 is clearly 2050. The old implementation first converted to float:

  1. 2049.0001m to float rounds to 2049, because the float spacing at this magnitude is about 0.000244.
  2. 2049f to Half is now an exact tie between 2048 and 2050, and ties round to even: 2048.

.NET 10 returns 2048. .NET 11 returns 2050. This is the classic double-rounding error, and it affected decimal to Half and decimal to BFloat16 because both went through float.

For machine learning pipelines that quantize decimal source data into Half or BFloat16, the new result is one unit in the last place different for exactly these boundary values. Expected tensors in tests may need an update.

BigInteger: truncation is gone

BigInteger to double used to drop the bits that did not fit into the 53-bit significand. That is truncation toward zero, not rounding.

C#
1using System.Globalization;
2using System.Numerics;
3
4BigInteger value = long.MaxValue / 2;
5double converted = (double)value;
6
7Console.WriteLine(converted.ToString("G17", CultureInfo.InvariantCulture));

.NET 10:

4.6116860184273874E+18

.NET 11:

4.6116860184273879E+18

long.MaxValue / 2 is 2^62 - 1. The nearest double is 2^62. Truncation produced 2^62 - 512 instead.

The detail that makes this a proper bug: (double)(long.MaxValue / 2) - the same value as long - has always returned 4.6116860184273879E+18. In .NET 10, the same number converted to two different double values depending on whether it was stored in a long or a BigInteger:

C#
1BigInteger big = long.MaxValue / 2;
2long small = long.MaxValue / 2;
3
4Console.WriteLine((double)big == (double)small); // .NET 10: False, .NET 11: True

BigInteger to float, Half and BFloat16 went through double and could round twice as well. All four conversions are now correctly rounded.

Compile-time constants follow the SDK, not the target framework

One detail in the documentation is easy to overlook and makes the migration less predictable: when a conversion is a compile-time constant, the compiler evaluates it. A compiler shipped with the .NET 11 SDK uses the new algorithm - regardless of the target framework.

C#
1const decimal Constant = (decimal)1.23;
2
3double input = 1.23;
4decimal atRuntime = (decimal)input;

Built with the .NET 11 SDK, targeting net10.0 and running on .NET 10.0.14:

1const (decimal)1.23    = 1.2299999999999999822364316060
2runtime (decimal)1.23  = 1.23

The constant has the new value, the runtime conversion has the old value, and both come from the same source text in the same assembly.

This has two practical consequences:

  • Upgrading the SDK changes behavior even if the project still targets .NET 8, 9 or 10. A rebuild is enough.
  • global.json matters. A developer machine with the .NET 11 SDK and a build agent pinned to the .NET 10 SDK can produce different constants from the same commit.

Library authors that multi-target should not rely on (decimal) applied to a double literal at all. A decimal literal is unambiguous on every compiler and every runtime.

What to do

There is no AppContext switch for this change. The old algorithms are gone.

Use decimal literals for decimal values

C#
1decimal exact = 0.19m;                 // exactly 0.19
2decimal approximated = (decimal)0.19;  // 0.1900000000000000022204460493 in .NET 11

This is the most common case and the simplest fix. A cast from a double literal to decimal has no advantage over the m suffix.

Keep decimal values out of double

DTOs, entity properties, Excel and CSV mappings and API contracts that carry money, quantities, rates or percentages should use decimal end to end. If an external source only delivers double, the conversion belongs at the boundary, with an explicit business rounding rule:

C#
1public static decimal ToMoney(double value)
2{
3    // The source system guarantees at most 4 fractional digits.
4    return Math.Round((decimal)value, 4, MidpointRounding.ToEven);
5}

This rounding removes binary noise because the documented precision of the source is far coarser than the error. It does not fix midpoint cases such as 2.675: once the value is a double, the information whether it was meant as half is gone. That is a contract problem with the source, not a conversion problem.

Restore the old result only where it is required

If a calculation has to reproduce historical results exactly - an audited report, a regulatory figure, a comparison against stored values - the old behavior can be reproduced. The previous conversion rounded to 7 significant digits for float and 15 for double, round-half-to-even. Formatting with G7 or G15 and parsing the result does the same:

C#
 1using System.Globalization;
 2
 3public static class LegacyDecimalConversion
 4{
 5    public static decimal FromSingle(float value)
 6    {
 7        Span<char> text = stackalloc char[32];
 8        value.TryFormat(text, out int length, "G7", CultureInfo.InvariantCulture);
 9        return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
10    }
11
12    public static decimal FromDouble(double value)
13    {
14        Span<char> text = stackalloc char[32];
15        value.TryFormat(text, out int length, "G15", CultureInfo.InvariantCulture);
16        return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
17    }
18}

The implementation does not allocate, and values outside the decimal range still throw during parsing, as they did during conversion.

Two overloads are deliberate. A float passed to the double overload is widened first and formatted with 15 digits: 14.1f becomes 14.1000003814697 instead of 14.1.

This helper is a migration tool, not a default. Wrapping every conversion in it keeps the old bug and adds a dependency on a string round trip.

Update tests and snapshots consciously

Tests that fail after the upgrade fall into two groups:

  • The expected value encoded the old, wrong result. BigInteger truncation, double-rounded Half values and decimal to double results belong here. Update the expected value.
  • The test exposes a double in a decimal domain. Price, tax and quantity calculations belong here. Change the production type, not the test.

Blindly regenerating approval files hides the second group again.

If a protocol or file format requires a specific legacy bit pattern for float, double, Half or BFloat16, encode that bit pattern explicitly with BitConverter or BinaryPrimitives instead of reproducing it through a numeric conversion.

Finding affected code

The conversions are ordinary casts, so no analyzer flags them. A search over the codebase is still effective:

  • (decimal), new decimal( and Convert.ToDecimal( with a double or float operand;
  • decimal.CreateChecked, CreateSaturating and CreateTruncating in generic math code;
  • (double), (float), (Half) and (BFloat16) with a decimal or BigInteger operand;
  • Convert.ToDouble( and Convert.ToSingle( with a decimal operand.

Typical hotspots are mapping code between DTOs and domain models, report and export generators, Excel imports, EF Core value converters, calculation engines and every place where a double property sits next to a decimal property with the same meaning.

Design rule

The old double to decimal conversion rounded far more than its destination type required. It made binary floating-point values look decimal, and code built on that illusion now has to choose: be decimal from the start, or round explicitly with a business rule.

The other conversions - decimal to double, float, Half and BFloat16, and BigInteger to all binary floating-point types - simply had bugs. .NET 11 fixes them.

Code that never mixes double and decimal will not notice anything. Code that does was already wrong, and .NET 11 finally shows it.

Further reading

  • Breaking change: Decimal and BigInteger floating-point conversions are correctly rounded
  • dotnet/runtime#130565
  • dotnet/runtime#130566
  • System.Decimal API reference
  • Floating-point numeric types in C#
  • Math.Round and MidpointRounding

Related articles

Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Oct 27, 2026 - 10 min read

Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Distroless, Chiseled and Alpine are often compared as three equivalent Linux distributions. They are not. Alpine is a distribution built …

Containerizing .NET in 2026 Without a Dockerfile

Oct 20, 2026 - 11 min read

Containerizing .NET in 2026 Without a Dockerfile

Most .NET Dockerfiles repeat the same five steps: restore, publish, select the matching Microsoft runtime image, copy the output and start …

Problem Details in ASP.NET Core and .NET 11

Oct 5, 2026 - 12 min read

Problem Details in ASP.NET Core and .NET 11

builder.Services.AddProblemDetails() registers infrastructure. It does not create an error contract. An ASP.NET Core request can fail during …


BEN ABT

BEN ABT

Ben is Chief Engineering Officer and Principal Software Engineer at Medialesson.de, with a strong focus on .NET and Microsoft Azure. In his professional role, he designs and builds highly scalable platforms for hybrid and cloud-based scenarios. He also advises companies and C-levels on migrating to the sovereign cloud.

He runs myCSharp.de, the largest and most active German-speaking C# community forum, actively contributes to both his own and external open-source projects and regularly shares his knowledge through his blog, at community events, conferences and as a writer for technical magazines.

Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms

Modernization

Training & Consulting

Contact Me
X Facebook LinkedIn WhatsApp
Back

Open for work!

Contact Me
BEN ABT BEN ABT Software Engineer · Microsoft MVP Passionate about .NET, Azure, performance optimization and sustainable software engineering.

Medialesson GmbH Logo

Microsoft MVP Logo

Recent posts

View all
The Big Decimal Bugfix with .NET 11

The Big Decimal Bugfix with .NET 11

Nov 2, 2026
Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Oct 27, 2026
Stop Using nginx as Your Default Static File Server in Docker

Stop Using nginx as Your Default Static File Server in …

Oct 24, 2026
Containerizing .NET in 2026 Without a Dockerfile

Containerizing .NET in 2026 Without a Dockerfile

Oct 20, 2026
Problem Details in ASP.NET Core and .NET 11

Problem Details in ASP.NET Core and .NET 11

Oct 5, 2026

Tags

.NET (194) Csharp (98) ASP.NET Core (48) Azure (41) DevOps (17) Docker (15) Microsoft (14) EF Core (11) Microsoft MVP (11) AI (10) GitHub (10) GitHub Copilot (10) Security (10) ASP.NET (9) Synology (9) Architecture (8) Raspberry (8) .NET 11 (7) Azure Functions (7) Cloudflare (7)
benjamin-abt.com

About BEN

Benjamin Abt is a Software Engineer, Platform Architect and Microsoft MVP specializing in .NET, Azure, cloud architecture, performance optimization and sustainable software development.

Connect

  • Follow me on GitHub
  • Follow me on LinkedIn
  • Follow me on Mastodon
  • Microsoft MVP profile
  • Follow SchwabenCode on GitHub
  • Follow me on X

Quick Links

  • Portfolio
  • Blog
  • Workshops & Training
  • Publications
  • Get in Touch
  • Sitemap
Open for Work

© Copyright Benjamin Abt & SchwabenCode | 2011 - 2026

Content licensed under CC BY 4.0 unless otherwise noted.

  • Impressum
  • Privacy
  • Contact