newsletter

New Features in .NET 11 and C# 15

10 min read

.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!

Copied

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:

xml
<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:

csharp
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:

text
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:

text
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):

xml
<PropertyGroup> <UseRuntimeAsync>false</UseRuntimeAsync> </PropertyGroup>
Copied

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:

csharp
if (!span.IsEmpty && span[0] == value) { // The bounds check on span[0] is now eliminated }

It also folds string comparisons with constants after inlining:

csharp
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.

Copied

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:

csharp
#: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:

csharp
#: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:

csharp
#:property Company="Anton Dev Tips" #:package System.CommandLine@2.0.1 Aliases=CommandLine

The CLI gets 3 new options:

bash
# 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.

Copied

What's New in C# 15

C# 15 brings the biggest change to the type system in years.

Here are the key features:

Copied

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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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.

Copied

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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
[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
Copied

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:

csharp
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.

Copied

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:

csharp
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:

csharp
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).

Copied

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:

csharp
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:

csharp
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.

Copied

What's New in .NET 11 Libraries

Copied

LeftJoin, RightJoin and FullJoin Return Tuples

.NET 10 added LeftJoin and RightJoin to LINQ, but you still had to provide a result selector:

csharp
// .NET 10 var orderShipments = orders.LeftJoin( shipments, order => order.Id, shipment => shipment.OrderId, (order, shipment) => new { Order = order, Shipment = shipment });

.NET 11 adds overloads that return tuples directly. You can deconstruct them in a foreach loop:

csharp
var orderShipments = orders.LeftJoin( shipments, order => order.Id, shipment => shipment.OrderId); foreach (var (order, shipment) in orderShipments) { Console.WriteLine($"{order.Number}: {shipment?.Number ?? "not shipped yet"}"); }

.NET 11 also adds a new FullJoin operator. It returns all elements from both sequences and pairs them where the keys match:

csharp
var report = orders.FullJoin( shipments, order => order.Id, shipment => shipment.OrderId); foreach (var (order, shipment) in report) { var orderNumber = order?.Number ?? "(no order)"; var shipmentNumber = shipment?.Number ?? "(no shipment)"; Console.WriteLine($"{orderNumber} -> {shipmentNumber}"); }

I'd reach for this in reconciliation reports, where you need to find orders without shipments and shipments without orders in a single pass.

The classic Join and GroupJoin operators also get tuple-returning overloads with an optional IEqualityComparer<T>:

csharp
var orderItems = orders.Join(items, order => order.Id, item => item.OrderId); foreach (var (order, item) in orderItems) { Console.WriteLine($"{order.Number}: {item.Sku}"); }

All these operators work with Enumerable, Queryable, and AsyncEnumerable.

Copied

Process.RunAndCaptureTextAsync

Running an external process and reading its output has always required a lot of setup:

csharp
// Before .NET 11 var startInfo = new ProcessStartInfo("git", "status --porcelain") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using var process = Process.Start(startInfo)!; var output = await process.StandardOutput.ReadToEndAsync(); var error = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync();

.NET 11 does this in a single call:

csharp
ProcessTextOutput result = await Process.RunAndCaptureTextAsync( "git", ["status", "--porcelain"]); Console.WriteLine(result.StandardOutput); Console.WriteLine($"Exit code: {result.ExitStatus.ExitCode}");

RunAndCaptureTextAsync starts the process, captures stdout and stderr, and waits for the exit code.

This pairs nicely with file-based apps, where you often write scripts that call git, docker, or kubectl.

The Process class gets more helpers as well:

  • Process.RunAsync runs a process and returns its exit status
  • Process.StartAndForget starts a process that you don't intend to wait for
  • Process.TryGetProcessById looks up a process without throwing an exception
Copied

JSON Lines Output

JSON Lines (JSONL) is a format where each line is a separate JSON value. It's popular for data exports and AI datasets, because you can process it line by line without loading the whole file.

JsonSerializer.SerializeAsyncEnumerable now writes JSON Lines when you set topLevelValues: true:

csharp
public record OrderExport(string Number, string CustomerName, decimal Amount); await using var file = File.Create("orders.jsonl"); await JsonSerializer.SerializeAsyncEnumerable( file, GetOrdersAsync(), topLevelValues: true);

The output looks like this:

json
{"Number":"ORD-001","CustomerName":"Alice","Amount":99.99} {"Number":"ORD-002","CustomerName":"Bob","Amount":250.00} {"Number":"ORD-003","CustomerName":"Charlie","Amount":15.50}

The serializer writes each value compactly, followed by a line feed. It streams data from IAsyncEnumerable<T>, so you can export millions of rows from a database without keeping them all in memory.

You can see the complete list of library changes in .NET 11 here.

Copied

What's New in ASP .NET Core in .NET 11

Copied

Async Validation in Minimal APIs

ASP .NET Core 10 added built-in validation for Minimal APIs. But validation was synchronous, so you couldn't check the database inside a validation attribute without blocking a thread.

.NET 11 adds async validation with the new AsyncValidationAttribute and IAsyncValidatableObject types.

Here's an attribute that checks that an order number is unique:

csharp
public sealed class UniqueOrderNumberAttribute : AsyncValidationAttribute { protected override ValidationResult? IsValid(object? value, ValidationContext context) => throw new InvalidOperationException("Validate this attribute with IsValidAsync."); protected override async Task<ValidationResult?> IsValidAsync( object? value, ValidationContext context, CancellationToken cancellationToken) { var dbContext = context.GetRequiredService<OrdersDbContext>(); if (value is string number && await dbContext.Orders.AnyAsync(o => o.Number == number, cancellationToken)) { return new ValidationResult($"Order {number} already exists."); } return ValidationResult.Success; } }

You derive from AsyncValidationAttribute and implement IsValidAsync. The synchronous IsValid method is abstract too, so you throw from it when the attribute only validates asynchronously.

Apply the attribute like any built-in validation attribute:

csharp
public record CreateOrderRequest( [Required, UniqueOrderNumber] string Number, [Required] string CustomerName, [Range(1, 1_000_000)] decimal Amount); builder.Services.AddValidation(); app.MapPost("/orders", (CreateOrderRequest request) => { // The request is already validated here return Results.Ok(request); });

The framework validates the request before your endpoint runs. When validation fails, it returns a 400 Bad Request with the validation errors.

For rules that span several properties, implement IAsyncValidatableObject and return results as an IAsyncEnumerable<ValidationResult>.

Validators run concurrently where possible: async attributes on the same property start together, and collection items validate in parallel.

Note: The same async validation works for Microsoft.Extensions.Options through the new IAsyncStartupValidator.

Copied

Built-in Validation Localization

Validation messages and display names can now be localized without a separate package.

Call AddLocalization before AddValidation, and localization turns on automatically:

csharp
builder.Services.AddLocalization(); builder.Services.AddValidation();

Use resource keys in your validation attributes:

csharp
[ValidatableType] public class CustomerModel { [Display(Name = "CustomerName")] // resource key for the display name [Required(ErrorMessage = "NameRequired")] // resource key for the message public string? Name { get; set; } }

You don't have to use resource files. If you register your own IStringLocalizerFactory, validation messages come from your store, for example a database:

csharp
builder.Services.AddSingleton<IStringLocalizerFactory, DbStringLocalizerFactory>(); builder.Services.AddValidation();

The same rules work for Minimal APIs and Blazor, so a message is translated the same way wherever the model is used.

Copied

Native OpenTelemetry Tracing

Until now, you needed the OpenTelemetry.Instrumentation.AspNetCore package to get proper HTTP traces from ASP .NET Core.

In .NET 11, ASP .NET Core adds OpenTelemetry attributes to its HTTP server activity natively. You only need to subscribe to the Microsoft.AspNetCore activity source:

csharp
builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource("Microsoft.AspNetCore") .AddOtlpExporter());

I only wish we had a constant for this.

Copied

OpenAPI 3.2 by Default

ASP .NET Core 10 made OpenAPI 3.1 the default. ASP .NET Core 11 moves to OpenAPI 3.2.

If your tools don't support OpenAPI 3.2 yet, you can switch back:

csharp
builder.Services.AddOpenApi(options => { options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_1; });

OpenAPI 3.2 can describe streaming responses. Endpoints that return Server-Sent Events with SseItem<T> now get a proper schema for each event:

csharp
app.MapGet("/stocks/stream", (StockService stockService, CancellationToken ct) => TypedResults.ServerSentEvents(GetStockPricesAsync(stockService, ct))); static async IAsyncEnumerable<SseItem<StockPriceEvent>> GetStockPricesAsync( StockService stockService, [EnumeratorCancellation] CancellationToken ct = default) { await foreach (var price in stockService.GenerateStockPrices(ct)) { yield return new SseItem<StockPriceEvent>(price) { EventId = price.Id }; } }

The generated document describes the payload with itemSchema, instead of a plain string:

yaml
responses: '200': description: OK content: text/event-stream: itemSchema: type: object required: [data] properties: data: $ref: '#/components/schemas/StockPriceEvent' event: { type: string } id: { type: string }

Tools that read your OpenAPI document now see the real shape of each event.

Note: Return the stream through TypedResults.ServerSentEvents. A handler that returns IAsyncEnumerable<SseItem<T>> directly is serialized as JSON instead of SSE.

OpenAPI also gets support for binary file responses (type: string, format: binary), HTTP QUERY operations, and [Obsolete] endpoints marked as deprecated.

Copied

MCP Server Template

The Model Context Protocol (MCP) is an open standard that AI tools like Claude Code, Codex, GitHub Copilot and Visual Studio use to call external tools and data.

The mcpserver project template now ships with the .NET SDK, so you don't need to install a separate package:

bash
dotnet new mcpserver -o Shipments.McpServer

The generated project uses the official C# MCP SDK and includes a sample tool. Program.cs registers the MCP server and discovers tools in your assembly:

csharp
builder.Services .AddMcpServer() .WithStdioServerTransport() .WithToolsFromAssembly();

A tool is a method marked with attributes:

csharp
[McpServerToolType] public static class ShipmentTools { [McpServerTool, Description("Returns the estimated delivery date for a shipment.")] public static string GetDeliveryEstimate(string trackingNumber) => $"Shipment {trackingNumber} arrives on {DateTime.UtcNow.AddDays(3):yyyy-MM-dd}"; }

An AI agent can now call GetDeliveryEstimate when a user asks about a shipment.

You can see the complete list of ASP .NET Core 11 features here.

Copied

What's New in EF Core 11

Copied

Better SQL for To-One Joins

EF Core 11 generates better SQL when you include reference navigations. This change speeds up existing queries without any code changes.

Consider this split query:

csharp
var orders = await context.Orders .Include(o => o.Customer) .Include(o => o.Items) .AsSplitQuery() .ToListAsync();

Before EF Core 11, the query for Items contained a join to Customers that nobody needed:

sql
-- Before EF Core 11 SELECT [i].[Id], [i].[OrderId], [i].[Sku], [o].[Id], [c].[Id] FROM [Orders] AS [o] INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id] INNER JOIN [OrderItems] AS [i] ON [o].[Id] = [i].[OrderId] ORDER BY [o].[Id], [c].[Id]

EF Core 11 removes the unneeded join:

sql
-- EF Core 11 SELECT [i].[Id], [i].[OrderId], [i].[Sku], [o].[Id] FROM [Orders] AS [o] INNER JOIN [OrderItems] AS [i] ON [o].[Id] = [i].[OrderId] ORDER BY [o].[Id]

EF also stops adding redundant reference navigation keys to ORDER BY in single queries. The order's key already identifies the row, so the customer's key isn't needed.

The EF team measured a 29% improvement for a common split query and a 22% improvement for a single query.

If you want to learn more about query performance, check out my step-by-step guide to EF Core optimization.

Copied

FullJoin Operator

EF Core 10 added LeftJoin and RightJoin. EF Core 11 translates the new .NET 11 FullJoin operator to SQL FULL JOIN:

csharp
var results = await context.Orders .FullJoin( context.Shipments, order => order.Id, shipment => shipment.OrderId, (order, shipment) => new { OrderNumber = order.Number, ShipmentNumber = shipment.Number }) .ToListAsync();

This generates the following SQL:

sql
SELECT [o].[Number] AS [OrderNumber], [s].[Number] AS [ShipmentNumber] FROM [Orders] AS [o] FULL JOIN [Shipments] AS [s] ON [o].[Id] = [s].[OrderId]

Before EF Core 11, you had to write raw SQL or combine two left joins with Union to get the same result.

Copied

Complex Types Improvements

EF Core 10 made complex types a first-class alternative to owned entities. EF Core 11 removes several of their limitations.

Complex Types with TPT and TPC Inheritance

You can now use complex types and JSON columns on entities with TPT (table-per-type) and TPC (table-per-concrete-type) inheritance:

csharp
public abstract class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public required ProductDetails Details { get; set; } } public class PhysicalProduct : Product { public decimal WeightKg { get; set; } } public class DigitalProduct : Product { public string DownloadUrl { get; set; } = string.Empty; } [ComplexType] public class ProductDetails { public string? Description { get; set; } public string? Manufacturer { get; set; } }
csharp
modelBuilder.Entity<Product>() .UseTptMappingStrategy() .ComplexProperty(p => p.Details, b => b.ToJson());

Keys and Indexes on Complex Type Properties

Keys and indexes can now use properties inside complex types:

csharp
modelBuilder.Entity<Customer>() .HasIndex(c => c.Address.PostalCode);

For JSON columns, you can index paths inside complex collections. Use [] to index all elements:

csharp
modelBuilder.Entity<Order>() .ComplexCollection(o => o.Items, b => b.ToJson()); modelBuilder.Entity<Order>() .HasIndex("Items[].Sku");

Simpler Configuration

Previously, you had to call ComplexProperty first to configure a property of a complex type:

csharp
modelBuilder.Entity<Product>() .ComplexProperty(p => p.Details) .Property(d => d.Description) .HasMaxLength(500);

Now you can reach the nested property directly:

csharp
modelBuilder.Entity<Product>() .Property(p => p.Details.Description) .HasMaxLength(500);

The EF team also fixed a long list of complex-type bugs, so you can migrate from owned entities with greater confidence.

Copied

MaxBy and MinBy

EF Core 11 translates MaxBy and MinBy, and their async versions. They return the whole element with the largest or smallest key. Max and Min only return the key itself.

Here's how to find the customer with the most orders:

csharp
var topCustomer = await context.Customers .MaxByAsync(c => c.Orders.Count());

This translates to the following SQL:

sql
SELECT TOP(1) [c].[Id], [c].[Name] FROM [Customers] AS [c] ORDER BY ( SELECT COUNT(*) FROM [Orders] AS [o] WHERE [c].[Id] = [o].[CustomerId]) DESC

Previously, you had to write OrderByDescending(...).FirstOrDefaultAsync() for the same result.

Copied

EF Core 10 added EF.Functions.VectorDistance() for exact vector search in SQL Server. Exact search compares the query against every row, which gets slow on large tables.

EF Core 11 supports vector indexes and approximate search with SQL Server 2025 VECTOR_SEARCH():

csharp
modelBuilder.Entity<Product>() .HasVectorIndex(p => p.Embedding, "cosine");
csharp
var similarProducts = await context.Products .VectorSearch(p => p.Embedding, queryEmbedding, "cosine") .OrderBy(r => r.Distance) .Take(5) .WithApproximate() .ToListAsync();

WithApproximate() tells SQL Server to use the vector index for approximate nearest neighbor (ANN) search. VectorSearch() returns VectorSearchResult<TEntity>, so you get the distance alongside the entity.

Vector columns are also no longer loaded by default when you query entities. Vectors can contain thousands of numbers, and this change alone made a benchmark 9x faster locally and 22x faster against Azure SQL.

EF Core 11 also supports SQL Server full-text table-valued functions, which return a relevance rank:

csharp
var results = await context.Products .FreeTextTable("wireless headphones", p => p.Name) .Select(r => new { Product = r.Value, r.Rank }) .OrderByDescending(r => r.Rank) .ToListAsync();

You can combine full-text search and vector search with FullJoin to build hybrid search. Full-text search finds exact word matches, vector search finds results with a similar meaning, and you merge both rankings. You can find the complete example in the EF Core documentation.

Note: VECTOR_SEARCH() and vector indexes are still experimental in SQL Server, and the EF Core APIs may change.

Copied

Migrations Tooling

EF Core 11 makes migrations easier to manage, especially in teams.

Create and Apply a Migration in One Step

The dotnet ef database update command has a new --add option. It creates a migration, compiles it with Roslyn, and applies it in one step:

bash
dotnet ef database update AddShipmentTracking --add

The migration files are still saved to disk for source control.

Configuration File for dotnet ef

If you work with a multi-project solution, you know the pain of typing --project and --startup-project with every command.

EF Core 11 reads default options from a .config/dotnet-ef.json file:

json
{ "project": "src/Shipments.Infrastructure", "startupProject": "src/Shipments.Api", "context": "ShipmentsDbContext" }

Now dotnet ef migrations add AddCarrier works without extra options. Options you pass on the command line still win.

Run Commands for All DbContexts

If your project has multiple DbContext types, you can run several commands for all of them at once with --context "*":

bash
dotnet ef database update --context "*" dotnet ef migrations list --context "*"

This is great for modular monoliths, where each module has its own DbContext.

Detect Diverged Migrations

When two developers create migrations on separate branches, the migration history can split without anyone noticing.

EF Core 11 records the latest migration ID in the model snapshot. If two branches both add migrations, they both change this value, and Git reports a merge conflict. The conflict tells your team to fix the migrations before they reach the database.

The dotnet ef migrations remove command also gets --connection and --offline options, so you can remove a migration without a configured database connection.

You can see the complete list of EF Core 11 features here.

Copied

Breaking Changes in .NET 11

Check these changes before you upgrade:

  • New minimum CPU requirements: x86/x64 now requires x86-64-v2, and Windows on Arm64 requires the LSE instruction set. .NET 11 won't start on older hardware (details).
  • BackgroundService failures: IHost.RunAsync and IHost.StopAsync now throw when a BackgroundService fails.
  • Async options validation: Reading async-validated options synchronously throws an exception.
  • Saturating conversions: float and double conversions to small integer types now saturate to the type bounds instead of wrapping.
  • Stricter archives: Reading ZIP entries validates CRC32, and reading TAR archives validates header checksums. Corrupted files now throw InvalidDataException.
  • OpenAPI 3.2: Generated documents target OpenAPI 3.2 by default, and Microsoft.OpenApi 3.x has breaking API changes.
  • EF Core SQL Server compatibility level: UseSqlServer now defaults to compatibility level 160 (SQL Server 2022) (details).
  • EF Core and Microsoft.Data.SqlClient 7.0: EF Core 11 updates the SQL Server driver to a new major version.
  • EF Core migrations: EF Core now throws by default when no migrations are found.
  • EF Core Cosmos DB: The provider switches from Newtonsoft.Json to System.Text.Json, removes the __jObject shadow property, and no longer preserves unmapped JSON properties.

You can see the complete list of breaking changes in .NET 11 and EF Core 11.

Copied

Other Changes in .NET 11

Copied

Summary

.NET 11 and C# 15 change how we model data and how async code runs.

  • Union types and closed hierarchies let the compiler check that you handle every case. Missing cases become build warnings instead of runtime bugs.
  • Runtime Async gives you cleaner stack traces and faster async code, and the .NET libraries already use it.
  • C# 15 quality-of-life features like collection expression arguments, extension indexers, and labeled break and continue remove small but common friction.
  • ASP .NET Core 11 adds async validation, built-in validation localization, native OpenTelemetry tracing, and OpenAPI 3.2.
  • EF Core 11 generates faster SQL for includes, supports FullJoin and MaxBy, and makes migrations easier to manage in a team.

.NET 11 is an STS release with 24 months of support. If you're on .NET 10 LTS, there is no rush to migrate production apps. But union types and Runtime Async are worth upgrading for sure (I will be migrating my projects in November and December).

Additional resources for .NET 11:

Are you excited about .NET 11? Do you plan to upgrade your projects to this version?

Leave a comment below and let me know.

Hope you find this newsletter useful. See you next time.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design — answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
Join 500+ developers

Not sure where you stand? Take the free .NET interview test:

  • Find out your real level — Junior to Senior+
  • A realistic mock .NET interview — across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next — the perfect way to benchmark yourself before diving into the Playbook.

Start the free test

Master .NET and Software Architecture,
so AI can't replace you

Real-world architecture judgment, production trade-offs, and .NET in depth — the things that keep you relevant in the job market.

Plus a free PDF: 200 senior .NET interview questions on C#, ASP.NET Core, EF Core & System Design.

Join 28,000+ developers already reading
No spam. Unsubscribe any time.

Enjoyed this article? Share it with your network