.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 ExceptionDispatchInfo removes 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 await went 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>.Default run 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 explicit abstract modifier
  • 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 (int or string, CreditCard or PayPal)
  • 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