.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!
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:
csharpawait 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:
textat 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:
textat 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):
xml<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:
csharpif (!span.IsEmpty && span[0] == value) { // The bounds check on span[0] is now eliminated }
It also folds string comparisons with constants after inlining:
csharpstatic 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:
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.
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:
csharppublic 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:
csharpPaymentMethod 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:
csharppublic 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:
csharpapp.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:
csharppublic 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:
csharppublic 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:
csharpstring 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 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:
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 (
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:
csharpstring[] 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:
csharppublic 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:
csharpvar 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:
csharpvar 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:
csharpouter: 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.
What's New in .NET 11 Libraries
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:
csharpvar 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:
csharpvar 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>:
csharpvar 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.
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:
csharpProcessTextOutput 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.RunAsyncruns a process and returns its exit statusProcess.StartAndForgetstarts a process that you don't intend to wait forProcess.TryGetProcessByIdlooks up a process without throwing an exception
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:
csharppublic 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.
What's New in ASP .NET Core in .NET 11
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:
csharppublic 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:
csharppublic 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.Optionsthrough the newIAsyncStartupValidator.
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:
csharpbuilder.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:
csharpbuilder.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.
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:
csharpbuilder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource("Microsoft.AspNetCore") .AddOtlpExporter());
I only wish we had a constant for this.
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:
csharpbuilder.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:
csharpapp.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:
yamlresponses: '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 returnsIAsyncEnumerable<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.
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:
bashdotnet 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:
csharpbuilder.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.
What's New in EF Core 11
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:
csharpvar 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.
FullJoin Operator
EF Core 10 added LeftJoin and RightJoin.
EF Core 11 translates the new .NET 11 FullJoin operator to SQL FULL JOIN:
csharpvar 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:
sqlSELECT [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.
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:
csharppublic 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; } }
csharpmodelBuilder.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:
csharpmodelBuilder.Entity<Customer>() .HasIndex(c => c.Address.PostalCode);
For JSON columns, you can index paths inside complex collections.
Use [] to index all elements:
csharpmodelBuilder.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:
csharpmodelBuilder.Entity<Product>() .ComplexProperty(p => p.Details) .Property(d => d.Description) .HasMaxLength(500);
Now you can reach the nested property directly:
csharpmodelBuilder.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.
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:
csharpvar topCustomer = await context.Customers .MaxByAsync(c => c.Orders.Count());
This translates to the following SQL:
sqlSELECT 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.
Vector Search and Hybrid Search
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():
csharpmodelBuilder.Entity<Product>() .HasVectorIndex(p => p.Embedding, "cosine");
csharpvar 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:
csharpvar 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.
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:
bashdotnet 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 "*":
bashdotnet 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.
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 theLSEinstruction set. .NET 11 won't start on older hardware (details). - BackgroundService failures:
IHost.RunAsyncandIHost.StopAsyncnow throw when aBackgroundServicefails. - Async options validation: Reading async-validated options synchronously throws an exception.
- Saturating conversions:
floatanddoubleconversions 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.OpenApi3.x has breaking API changes. - EF Core SQL Server compatibility level:
UseSqlServernow 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
__jObjectshadow property, and no longer preserves unmapped JSON properties.
You can see the complete list of breaking changes in .NET 11 and EF Core 11.
Other Changes in .NET 11
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
breakandcontinueremove 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
FullJoinandMaxBy, 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:
- Breaking changes in .NET 11
- What's New in the .NET 11 SDK
- What's New in the .NET 11 runtime
- What's New in .NET libraries for .NET 11
- What's New in ASP .NET Core in .NET 11
- What's New in EF Core 11
- What's New in C# 15
- Performance Improvements in .NET 11
- What's New in Aspire
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.

