.NET 11 and C# 15 is out today (November 10, 2026).
.NET 11 is a Standard-Term Support (STS) release.
STS releases now get 24 months of support, so .NET 11 will be supported until November 2028.
This release brings the language feature C# developers have been asking for over a decade: Union Types (Discriminated Unions).
The runtime starts replacing compiler-generated async state machines with native async support, a major step toward improved performance.
This is one of the best releases in .NET in years.
In this post, we will explore:
- What's New in .NET 11
- What's New in C# 15
- What's New in .NET 11 Libraries
- What's New in ASP .NET Core in .NET 11
- What's New in EF Core 11
- Breaking Changes in .NET 11
Let's dive in!
ð Read original article on my newsletter: https://antondevtips.com/blog/new-features-in-dotnet-11-and-csharp-15
What's New in .NET 11
Runtime Async
The biggest runtime change in .NET 11 is runtime-native async (Runtime Async).
Since C# 5, the compiler has turned every async method into a hidden state machine class.
This class tracks where your method paused and how to resume it.
It worked for over a decade, but it adds allocations, extra code, and noisy stack traces.
With Runtime Async, the runtime itself handles suspension and resumption.
The compiler no longer needs to generate state machines.
To enable Runtime Async in your project, add the following property:
<PropertyGroup>
<Features>runtime-async=on</Features>
</PropertyGroup>
The .NET runtime libraries themselves are already compiled with Runtime Async, so part of the benefit comes for free in every .NET 11 app.
Cleaner Live Stack Traces
The most visible change is in live stack traces: what profilers, debuggers, and new StackTrace() see while your code runs.
Consider three nested async methods:
await OuterAsync();
static async Task OuterAsync()
{
await Task.CompletedTask;
await MiddleAsync();
}
static async Task MiddleAsync()
{
await Task.CompletedTask;
await InnerAsync();
}
static async Task InnerAsync()
{
await Task.CompletedTask;
Console.WriteLine(new StackTrace(fNeedFileInfo: true));
}
Without Runtime Async, you get 13 frames with state-machine infrastructure mixed into your call chain:
at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
at Program.<<Main>$>g__InnerAsync|0_2()
at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
at Program.<<Main>$>g__MiddleAsync|0_1()
at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
at Program.<<Main>$>g__OuterAsync|0_0()
at Program.<Main>$(String[] args) in Program.cs:line 3
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
at Program.<Main>$(String[] args)
at Program.<Main>(String[] args)
With Runtime Async, you get 5 frames, which is your real call chain:
at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
at Program.<Main>$(String[] args) in Program.cs:line 3
at Program.<Main>(String[] args)
Breakpoints also bind correctly inside async methods, and the debugger steps over await without jumping into generated code.
Note: Exception stack traces already look clean today, because
ExceptionDispatchInforemoves the noise. The improvement is in what you see during live execution.
Faster Async Code
Runtime Async is also faster.
According to the Performance Improvements in .NET 11 post:
- Synchronous completion: A two-level async chain that completes synchronously went from 21.2 ns and 144 B to 6.2 ns and 0 B.
-
Real suspension: The same chain after a real
awaitwent from 254 ns to 117 ns. - Exceptions: Throwing through 30 async layers went from 51.3 Ξs and 84.2 KB to 10.7 Ξs and 5.7 KB.
- Code size: A ten-layer sample shrank from 10,752 to 5,632 bytes.
The runtime also skips capturing and restoring ExecutionContext when a continuation has nothing to restore.
ExecutionContext carries ambient state like AsyncLocal<T> across await points.
If you don't use AsyncLocal<T>, every await gets cheaper.
This works for Task, Task<T>, ValueTask, and ValueTask<T>, even without the Runtime Async switch.
If you want to stop using this feature, you can opt out per project (or via Directory.Build.props):
<PropertyGroup>
<UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>
Performance Improvements
Your existing code gets faster in .NET 11 without a single change.
The JIT compiler got smarter at removing checks you never asked for.
For example, it now removes the bounds check after an empty-span guard:
if (!span.IsEmpty && span[0] == value)
{
// The bounds check on span[0] is now eliminated
}
It also folds string comparisons with constants after inlining:
static bool IsAdmin(string role) => role == "Admin";
// When the caller passes "Guest", the JIT replaces the whole comparison with false
var isAdmin = IsAdmin("Guest");
A few more numbers from the release:
-
Enumerators on the stack: Iterating an
IEnumerable<int>field went from 13.9 ns and 32 B to 2.7 ns and 0 B. - Devirtualization: Calling a generic method through an interface went from 6.7 ns and 24 B to 1.8 ns and 0 B.
-
Default comparers in ReadyToRun: Collections that use
EqualityComparer<T>.Defaultrun up to 20x faster in R2R images. -
GUIDs on Linux:
Guid.NewGuid()is about 12% faster.
You can read about hundreds of other improvements in the Performance Improvements in .NET 11 post by Stephen Toub.
File-Based Apps
.NET 10 introduced file-based apps: you can run a single .cs file with dotnet run main.cs, without a project or a solution.
.NET 11 removes the biggest limitation: you can now split a file-based app across multiple files with the #:include directive:
#:include models/order.cs
#:include services/order-service.cs
var service = new OrderService();
var order = service.Create("ORD-001", 99.99m);
Console.WriteLine($"Created order {order.Number}");
You can also include compiled DLLs directly:
#:include ./libs/Shipping.Contracts.dll
var request = new Shipping.Contracts.CreateShipmentRequest("ORD-001");
The #:property directive now supports quoted values, and the #:package directive accepts item metadata like Aliases:
#:property Company="Anton Dev Tips"
#:package System.CommandLine@2.0.1 Aliases=CommandLine
The CLI gets 3 new options:
# Pass environment variables to the app
dotnet run -e ASPNETCORE_ENVIRONMENT=Development -e LOG_LEVEL=Debug main.cs
# Add a project reference to a file-based app
dotnet reference add --file main.cs ../Orders.Domain/Orders.Domain.csproj
# Format a file-based app
dotnet format main.cs
File-based apps start faster too, because the CLI reuses the cached Native AOT build output.
I wrote a detailed guide on file-based apps with multi-file support, including an HTTP health-check tool and a Minimal API with EF Core.
You can read it here.
You can see the complete list of new features in .NET 11 here.
What's New in C# 15
C# 15 brings the biggest change to the type system in years.
Here are the key features:
Union Types
A union declares that a value is exactly one of a fixed set of types.
The compiler checks that your switch expressions handle every case.
Before C# 15, you had two imperfect options:
-
object, which accepts any type at all - A marker interface or a base class, which anyone can implement or extend
- A third-party library that emulates this behavior
Neither lets the compiler know the full list of possible types.
Example 1: Payment Methods
A checkout service accepts several payment methods, and each one carries different data:
public record class CreditCard(string Number, string Holder, DateOnly Expiry);
public record class PayPal(string Email);
public record class BankTransfer(string Iban, string Bic);
public union PaymentMethod(CreditCard, PayPal, BankTransfer);
The case types don't need a common base class.
The compiler adds implicit conversions from each case type, so you can assign any of them directly:
PaymentMethod payment = new PayPal("john@example.com");
string description = payment switch
{
CreditCard card => $"Card ending in {card.Number[^4..]}",
PayPal paypal => $"PayPal account {paypal.Email}",
BankTransfer transfer => $"Bank transfer to {transfer.Iban}"
};
Notice there is no _ or default branch.
If you add a fourth payment method later, every switch that doesn't handle it produces a compiler warning.
Example 2: Handler Results
Unions are a natural fit for handlers that return one of several outcomes instead of throwing exceptions:
public record class OrderCreated(Guid OrderId, string Number);
public record class OrderAlreadyExists(string Number);
public record class InvalidOrder(string Reason);
public union CreateOrderResult(OrderCreated, OrderAlreadyExists, InvalidOrder);
The endpoint maps each outcome to an HTTP response:
app.MapPost("/orders", async (
CreateOrderRequest request,
CreateOrderHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(request, cancellationToken);
return result switch
{
OrderCreated created => Results.Created($"/orders/{created.OrderId}", created),
OrderAlreadyExists exists => Results.Conflict($"Order {exists.Number} already exists"),
InvalidOrder invalid => Results.BadRequest(invalid.Reason)
};
});
The compiler won't let you forget an outcome.
Example 3: Unions in JSON and APIs
System.Text.Json supports unions out of the box.
The serializer writes the active case directly, without a wrapper or $type field:
public union OrderReference(int, string);
app.MapGet("/orders/{id:int}/reference", async (int id, OrdersDbContext dbContext) =>
{
var order = await dbContext.Orders.FindAsync(id);
if (order?.ExternalNumber is not null)
{
return new OrderReference(order.ExternalNumber);
}
return new OrderReference(id);
});
This endpoint returns either the numeric ID, like 42, or the external order number, like "EXT-1042".
In OpenAPI, the response is described with an anyOf schema that lists each case type.
Unions work in Minimal API and MVC request and response bodies, SignalR, and Blazor.
They don't work for route values, query strings, headers, or form fields.
Union types offer much more: custom unions, null handling, and non-boxing access for performance-sensitive code.
Closed Hierarchies
Union types work great when the case types are unrelated.
But sometimes you have a hierarchy of related types, and you want the same exhaustiveness checks.
That's where closed hierarchies come in.
You can mark a class with the closed modifier.
A closed class can only be derived from inside its own assembly.
Here's a shipment status hierarchy:
public closed record class ShipmentStatus;
public record class Pending(DateTime CreatedAt) : ShipmentStatus;
public record class Dispatched(string Carrier) : ShipmentStatus;
public record class InTransit(string TrackingNumber) : ShipmentStatus;
public record class Delivered(DateTime DeliveredAt) : ShipmentStatus;
public record class Cancelled(string Reason) : ShipmentStatus;
Because the compiler knows every derived type, a switch expression doesn't need a default branch:
string Describe(ShipmentStatus status) => status switch
{
Pending pending => $"Pending since {pending.CreatedAt:g}",
Dispatched dispatched => $"Dispatched with {dispatched.Carrier}",
InTransit inTransit => $"In transit, tracking {inTransit.TrackingNumber}",
Delivered delivered => $"Delivered at {delivered.DeliveredAt:g}",
Cancelled cancelled => $"Cancelled: {cancelled.Reason}"
};
Code in another assembly can't add new statuses:
// In a consumer assembly
public record class Returned(DateTime ReturnedAt) : ShipmentStatus;
// ERROR: 'ShipmentStatus' is a closed class and cannot be derived outside its assembly
Rules for closed classes:
- A closed class is implicitly
abstract - It can't be combined with
sealed,static, or an explicitabstractmodifier - Derivation isn't transitive: a non-closed child of a closed class can still be extended in other assemblies
System.Text.Json can also infer the derived types of a closed hierarchy for polymorphic serialization:
[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record class ShipmentStatus;
You no longer need a [JsonDerivedType] attribute for every derived type.
When to use which:
-
Union types - when the cases are unrelated types (
intorstring,CreditCardorPayPal) - Closed hierarchies - when the cases share a base type and common data
Collection Expression Arguments
C# 12 introduced collection expressions with the [...] syntax.
But you couldn't pass arguments to the collection's constructor, like a capacity or a comparer.
C# 15 fixes this with a with(...) element as the first element of a collection expression:
string[] skus = ["SKU-001", "SKU-002", "SKU-003"];
// Pass capacity to the List<T> constructor
List<string> allSkus = [with(capacity: skus.Length * 2), .. skus];
// Pass a comparer to the HashSet<T> constructor
HashSet<string> uniqueSkus = [with(StringComparer.OrdinalIgnoreCase), "sku-001", "SKU-001", "Sku-001"];
// uniqueSkus contains only one element
Previously, you had to choose between the short collection expression syntax and a configured collection.
Now you can have both.
Extension Indexers
C# 14 introduced extension members: extension properties and static extension members.
C# 15 adds extension indexers.
You declare them in an extension block, just like other extension members:
public static class ShipmentExtensions
{
extension(Shipment shipment)
{
public ShipmentItem? this[string sku] =>
shipment.Items.FirstOrDefault(item => item.Sku == sku);
}
}
Now you can index into a shipment by product SKU as if the indexer was declared on the Shipment class:
var item = shipment["SKU-001"];
if (item is not null)
{
Console.WriteLine($"{item.Sku}: {item.Quantity} pcs");
}
Because indexers are always instance members, the extension block must name its receiver parameter (shipment in this example).
Labeled Break and Continue
When you work with nested loops, exiting the outer loop from the inner one has always been awkward.
You had to use a Boolean flag:
var foundInvalidItem = false;
foreach (var order in orders)
{
foreach (var item in order.Items)
{
if (item.Quantity <= 0)
{
foundInvalidItem = true;
break;
}
}
if (foundInvalidItem)
{
break;
}
}
Or a goto statement, which is meh.
In C# 15, break and continue can name a label on an enclosing loop:
outer: foreach (var order in orders)
{
foreach (var item in order.Items)
{
if (item.IsBackordered)
{
// Skip the whole order and move to the next one
continue outer;
}
if (item.Quantity <= 0)
{
// Stop processing all orders
break outer;
}
}
await ShipAsync(order);
}
Place the label directly on the loop it identifies.
Without a label, break and continue keep their original behavior and target the innermost loop.
For the first time, it might look weird.
But believe me, it's a good trade-off that doesn't hurt code readability and is not the same as goto.
The new IDE0410 analyzer finds Boolean flag and goto patterns that a labeled jump can replace.
You can see the complete list of new features in C# 15 here.
ð Read original article on my newsletter: https://antondevtips.com/blog/new-features-in-dotnet-11-and-csharp-15
Top comments (0)