.NET 11 is out now, meaning it's time to start moving to it. But first, let's see whether the new features in C# 15, such as Runtime Async and JIT optimizations, justify an immediate upgrade.

.NET 11 focuses on performance and runtime improvements. The release is a Standard Term Support (STS) release, so it gets two years of support. Starting with .NET 9, STS releases get 24 months of support instead of 18.
This article covers the most notable changes to the runtime, libraries, and SDK. For the complete list, see the official documentation.
All of the C# 15 features were covered in a separate article. The language gained union types, closed hierarchies, and extension indexers, and its approach to memory safety has been reworked. This year's changes are genuinely significant. What's your take on the new version?
Every year, Steven Taub publishes a detailed review of the performance improvements in the new .NET, walking through each change in depth.
This year's review covers the JIT, Runtime Async, devirtualization, bounds checking, and a good deal more.
For a thorough look at this part of .NET 11, see Steven Taub's article.
The System.IO.Compression namespace now includes Zstandard compression, through ZstandardStream, ZstandardEncoder, and related types. When reading ZIP archives, the CRC32 checksum is now verified, so a corrupted archive throws an InvalidDataException. ZIP entries now also support passwords and can be opened with the FileAccess option. Deflate, ZLib, and GZip encoders and decoders gained a Reset() method, which lets you clear an instance's state and reuse it instead of allocating a new object.
The new version improves the existing Join methods and adds FullJoin, which returns every element from both sequences. Elements are matched by key, and unmatched elements on either side are kept with no partner. Let's see FullJoin in action:
var products = new List<(string Name, string? Category)>
{
("Laptop", "Electronics"),
("Table", null)
};
var categories = new List<(string Name, string Description)>
{
("Electronics", "Electronic devices"),
("Furniture", "Office furniture")
};
var joined = products.FullJoin(categories, p => p.Category, c => c.Name);
foreach (var (product, category) in joined)
Console.WriteLine($"{product.Name ?? "(none)"}
: {category.Description ?? "(none)"}");
The code prints the following:
Laptop: Electronic devices
Table: (none)
(none): Office furniture
In addition, LINQ now lets you call Join and GroupJoin without a resultSelector, so they return tuples directly, just as LeftJoin, RightJoin, and FullJoin do.
The System.Text.Json namespace gained several capabilities. For the new union type introduced in C# 15, the serialization and deserialization methods were adapted so that the current value is written directly, with no wrapper or discriminator. A JSON Lines (JSONL) output format is also available, where each value sits on its own line. To do this, the new JsonSerializer.SerializeAsyncEnumerable method has a topLevelValues parameter.
The JsonPolymorphicAttribute has a new property, InferClosedTypePolymorphism, which turns on automatic inference of derived types for a single closed hierarchy without changing the serializer's global settings. Previously, every derived type had to be registered explicitly with the JsonDerivedType attribute on the base type. The setting can also be applied globally, through JsonSerializerOptions or the source generator options. In .NET 11, it looks like this:
[JsonPolymorphic(InferClosedTypePolymorphism = true)]
closed record Shape;
record Circle(double Radius) : Shape;
record Square(double Length) : Shape;
Generic GetTypeInfo<T>() and TryGetTypeInfo<T>() methods were added, both returning JsonTypeInfo<T>. The release also brings the built-in JsonNamingPolicy.PascalCase policy, the [JsonNamingPolicy] attribute for types and their members, the option to apply [JsonIgnore] to an entire type at once, and built-in converters for BFloat16, Decimal32, Decimal64, and Decimal128.
Asynchronous validation is now available through AsyncValidationAttribute and IAsyncValidatableObject, along with new methods such as Validator.ValidateObjectAsync, Validator.TryValidateObjectAsync, and others. It helps when a check has to query a database or an external service without blocking the thread. Microsoft.Extensions.Options can validate options asynchronously too, including at application startup through the new IAsyncStartupValidator interface.
Four new Stream types have arrived: ReadOnlyMemoryStream, WritableMemoryStream, ReadOnlySequenceStream, and StringStream. They let you hand an existing buffer or string to any API that accepts a Stream without copying it first, which means fewer allocations and less pressure on the garbage collector.
System.Numerics gets Decimal32, Decimal64, and Decimal128 types. These are IEEE 754-2019 decimal floating-point types with 7, 16, and 34 significant digits, respectively. Like System.Decimal, they store numbers in base 10, so 0.1 is represented exactly. Unlike System.Decimal, however, they follow the standard, supporting NaN, infinity, and generic math. They suit cases where IEEE 754 behavior matters, such as interoperating with other languages and databases that use the format. A generic Complex<T> also arrived, which works with float, Half, BFloat16, and the new decimal types.
A new method, INumberBase<TSelf>.TryParsePartial, which parses a number up to the first character that isn't part of it. Previously, input like 123;789 made TryParse return false, and leave the result at 0. Here is how it behaves now:
public static void ParseTest()
{
double.TryParsePartial("123;789",
System.Globalization.NumberStyles.Number,
null, out var result, out var consumed);
Console.WriteLine($"Parsed value (TryParsePartial): {result}");
Console.WriteLine($"Consumed: {consumed}");
double.TryParse("456;032", null, out result);
Console.WriteLine($"Parsed value (TryParse): {result}");
}
The output:
Parsed value (TryParsePartial): 123
Consumed: 3
Parsed value (TryParse): 0
So when something gets in the way, such as a wrong delimiter or a stray character, you now get a partial result along with the count of characters read.
The Process class now has a whole set of new APIs. Previously, running a utility and collecting its output meant subscribing to OutputDataReceived and ErrorDataReceived or reading the streams by hand. Now a single call does it:
ProcessTextOutput result = await Process.RunAndCaptureTextAsync("git",
["status", "--porcelain"]);
Console.WriteLine(result.StandardOutput);
Console.WriteLine($"Exit code: {result.ExitStatus.ExitCode}");
Three more methods came with it:
Process.StartAndForget starts a process without waiting for it to finish.ProcessStartInfo.StartSuspended starts a process without waiting for it to finish.SafeProcessHandle.Resume() resumes a suspended process.This is a major change to the runtime this year. For every async method, the compiler generates a state machine, whereas Runtime Async hands that work to the runtime itself. The feature started as an experiment in .NET 10. In .NET 11 it's still marked as a preview, but .NET 11 projects no longer need EnablePreviewFeatures to use it. The .NET libraries are already compiled with Runtime Async and contain no generated state machines. To enable it in your project, add this to the project file:
<PropertyGroup>
<Features>runtime-async=on</Features>
</PropertyGroup>
The gains come in two areas. First, overhead drops. The JIT now optimizes Task.FromResult, Task.CompletedTask, and ValueTask.FromResult into faster paths, reuses continuation objects, and applies tiered compilation to hot asynchronous methods. Second, the call stack obtained from new StackTrace() is now easier to read, since it shows only the actual call chain, excluding internal calls. Debugging improves as well, partly because stepping over an await no longer takes you into infrastructure code.
This change may stop the app from launching on older hardware. On x86/x64, the minimum level for JIT and NativeAOT is now x86-64-v2 instead of x86-64-v1. On a processor that falls short, the application simply won't run and will report that the basic instruction sets are missing.
The ReadyToRun images for Windows and Linux on x64 now target x86-64-v3 (AVX2, BMI2, FMA, and others). On processors without these instructions, the application still starts, but the precompiled code has to be recompiled by the JIT, which slows startup. Processors like that are hard to find these days, but it's worth checking in advance which hardware your project runs on.
Besides supporting Runtime Async, the JIT has picked up several useful tricks. It now removes more bounds checks, including those for expressions like values[^1] and i + const < length, as well as for span[0] after the !span.IsEmpty check. It can evaluate string.Equals and SequenceEqual at compile time when both operands are known. It also strips redundant conditions and branches, and emits a MUL instruction for Math.BigMul on x64.
In NativeAOT, interface method calls now go through a shared dispatch helper, which then redirects them to the appropriate implementation. Call sites get smaller, and code that works heavily with interfaces runs faster.
Comparer<T>.Default and EqualityComparer<T>.Default are now specialized during precompilation in ReadyToRun during precompilation, so they no longer need the JIT at runtime.
The dotnet CLI runs as a NativeAOT application by default. Commands such as dotnet --version, dotnet --info, and dotnet --help run entirely natively, while external tools, such as dotnet ef launch directly, skipping the managed part of the CLI.
The number of SDK installers for Linux and macOS has dropped: identical builds are now replaced by symbolic links to a single copy.
The ability to run .cs files without a project, introduced in .NET 10, keeps evolving. The #:include directive lets you to spread code across several files, or even reference a pre-built DLL.
These applications also support dotnet format now. Restarting an unmodified NativeAOT application reuses the output compiled earlier.
The dotnet run command can pass environment variables to the application straight from the command line using the -e parameter.
The dotnet watch command gained integration with the Aspire host, automatic restart after a crash, and device selection for MAUI projects.
The dotnet test command in Microsoft.Testing.Platform now comes with a lot of new options. For example, you can cap the run time or stop after a set number of failed tests.
A new --no-dependencies option skips building dependent projects. There's also an experimental mode that runs only the tests affected by your changes.
.NET 11 is, above all, a runtime release. Runtime Async is already in use inside the .NET libraries, the JIT has grown smarter, and the CLI starts faster. In everyday work, though, the features you'll notice most are the C# 15 additions and smaller but handy methods such as Process.RunAndCaptureTextAsync, FullJoin, and TryParsePartial.
If you're planning to upgrade, check the new processor requirements first.
The Microsoft documentation has the complete list of changes. Which new features did you like? Let us know in the comments.
For more articles on C# and .NET, visit the PVS-Studio blog, and subscribe here to catch new ones as they come out.
0