.NET 10 and C# 14 is out today (November 11, 2025).
As a Long-Term Support (LTS) release, .NET 10 will receive three years of support until November 14, 2028.
This makes it a solid choice for production applications that need long-term stability.
In this post, we will explore:
- What's New in .NET 10
- What's New in C# 14
- What's New in ASP.NET Core in .NET 10
- What's New in EF Core 10
- Other Changes in .NET 10
Let's dive in!
P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.
What's New in .NET 10
File-Based Apps
The biggest addition in .NET 10 is support for file-based apps.
This feature changes how you can write C# code for scripts and small utilities.
Traditionally, even the simplest C# application required three things: a solution file (sln), a project file (csproj), and your source code file (*.cs).
You would then use your IDE or the dotnet run command to build and run the app.
Starting with .NET 10, you can create a single *.cs file and run it directly:
dotnet run main.cs
This puts C# on equal with Python, JavaScript, TypeScript and other scripting languages.
This makes C# a good option for CLI utilities, automation scripts, and tooling, without a project setup.
File-based apps can reference NuGet packages and SDKs using special # directives at the top of your file.
This lets you include any library you need without a project file.
You can even create a single-file app that uses EF Core and runs a Minimal API:
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.EntityFrameworkCore.Sqlite@9.0.0
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder();
builder.Services.AddDbContext<OrderDbContext>(options =>
{
options.UseSqlite("Data Source=orders.db");
});
var app = builder.Build();
app.MapGet("/orders", async (OrderDbContext db) =>
{
return await db.Orders.ToListAsync();
});
app.Run();
return;
public record Order(string OrderNumber, decimal Amount);
public class OrderDbContext : DbContext
{
public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { }
public DbSet<Order> Orders { get; set; }
}
You can also reference existing project files from your script:
#:project ../ClassLib/ClassLib.csproj
Cross-Platform Shell Scripts
You can write cross-platform C# shell scripts that are executed directly on Unix-like systems.
Use the #! directive to specify the command to run the script:
#!/usr/bin/env dotnet
Then make the file executable and run it:
chmod +x app.cs
./app.cs
Converting to a Full Project
When your script grows and needs more structure, you can convert it to a regular project using the dotnet project convert command:
dotnet project convert app.cs
Note: Support for file-based apps with multiple files will likely come in future .NET releases.
You can see the complete list of new features in .NET 10 here.
What's New in C# 14
C# 14 is one of the most significant releases in recent years.
Let's explore the key features:
Extension Members
Extension members are my favorite feature in C# 14. They represent a modern evolution of extension methods that have been part of C# since version 3.0.
Traditional Extension Method Syntax
Before C# 14, you created extensions by writing static methods with a this parameter:
public static class StringExtensions
{
public static bool IsNullOrEmpty(this string value)
{
return string.IsNullOrEmpty(value);
}
public static string Truncate(this string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value.Substring(0, maxLength);
}
}
New Extension Keyword Syntax
The new syntax separates the receiver (the type you're extending) from the members you're adding. Instead of putting this on each method parameter, you declare an extension block that specifies the receiver once:
public static class StringExtensions
{
extension(string value)
{
public bool IsNullOrEmpty()
{
return string.IsNullOrEmpty(value);
}
public string Truncate(int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value;
return value.Substring(0, maxLength);
}
}
}
The extension block takes the receiver as a parameter.
Inside the block, you write your methods and properties just like they were actual members of the type.
The value parameter is available to all members within the block.
Both old and a new syntax compile to identical code, so they work the same way. You can even use both styles in the same static class.
The new extension syntax supports:
- Instance methods
- Instance properties
- Static methods
- Static properties
Extension Properties
Extension properties make your code more readable and expressive. Instead of calling methods, you can use properties that feel more natural.
For example, when working with collections, you frequently check if they're empty.
Instead of writing !items.Any() everywhere, you can create an IsEmpty property:
public static class CollectionExtensions
{
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
public bool HasItems => source.Any();
public int Count => source.Count();
}
}
public void ProcessOrders(IEnumerable<Order> orders)
{
if (orders.IsEmpty)
{
Console.WriteLine("No orders to process");
return;
}
foreach (var order in orders)
{
// Process order
}
}
Private Fields and Caching
Inside an extension block, you can define private fields and methods, just like in a regular class.
This is useful when you need to cache expensive calculations:
public static class CollectionExtensions
{
extension<T>(IEnumerable<T> source)
{
private List<T>? _materializedList;
public List<T> MaterializedList => _materializedList ??= source.ToList();
public bool IsEmpty => MaterializedList.Count == 0;
public T FirstItem => MaterializedList[0];
}
}
The backing field _materializedList ensures that ToList() is only called once, no matter how many times you access the properties.
Static Extension Members
Static extensions let you add factory methods or utility functions to a type, not the instance.
To create static extensions, use extension without naming the receiver parameter:
public static class ProductExtensions
{
extension(Product)
{
public static Product CreateDefault() =>
new Product
{
Name = "Unnamed Product",
Price = 0,
StockQuantity = 0,
Category = "Uncategorized",
CreatedDate = DateTime.UtcNow
};
public static bool IsValidPrice(decimal price) =>
price >= 0 && price <= 1000000;
public static string DefaultCategory => "General";
}
}
You can call these static members directly on the type:
var product = Product.CreateDefault();
if (Product.IsValidPrice(999.99m))
{
product.Price = 999.99m;
}
Null-Conditional Assignment
The null-conditional operators (?. and ?[]) can now be used for assignment, not just for accessing members.
Before C# 14, you needed an explicit null check before assigning to a property:
if (user is not null)
{
user.Profile = LoadProfile();
}
Now you can simplify this using the ?. operator:
user?.Profile = LoadProfile();
This is cleaner and more consistent with how null-conditional operators work for reading values.
The Field Keyword
The field keyword eliminates the need for explicit backing fields in many common property scenarios.
Previously, you needed to declare a private backing field:
public class Record
{
private string _msg;
public string Message
{
get => _msg;
set => _msg = value ?? throw new ArgumentNullException(nameof(value));
}
}
With the field keyword, you can reference the compiler-generated backing field directly:
public class Record
{
public string Message
{
get;
set => field = value ?? throw new ArgumentNullException(nameof(value));
}
}
You can use field in any accessor: get, set, or init.
If you already use "field" as a variable name in your code, you can avoid conflicts by using
@fieldorthis.field, or by renaming your existing variable.
The field keyword is particularly useful for lazy initialization and default values:
public class ConfigReader
{
public string FilePath
{
get => field ??= "data/config.json";
set => field = value;
}
public Dictionary<string, string> ConfigValues
{
get => field ??= new Dictionary<string, string>();
set => field = value;
}
}
This avoids declaring separate backing fields for FilePath and ConfigValues.
Lambda Parameters with Modifiers
C# 14 allows you to use parameter modifiers like out, ref, in, scoped, and ref readonly in lambda expressions without specifying parameter types.
Previously, you had to include complete type declarations when using modifiers:
delegate bool TryParse<T>(string text, out T result);
TryParse<int> parse = (string text, out int result) => Int32.TryParse(text, out result);
Now you can omit the types and let the compiler infer them:
delegate bool TryParse<T>(string text, out T result);
TryParse<int> parse = (text, out result) => Int32.TryParse(text, out result);
This makes lambda expressions more concise while maintaining type safety.
Partial Constructors and Events
C# 14 extends partial member support to include constructors and events. This is particularly useful for source generators.
Partial constructors must have one defining declaration and one implementing declaration:
public partial class User
{
// This is the defining part
public partial User(string name);
}
public partial class User
{
// This is the implementing part
public partial User(string name)
: this() // Calls another constructor in the same class
{
Name = name;
}
public User() { }
public string Name { get; set; }
}
Rules for partial constructors:
- Only the implementing part can use
this()orbase()to call another constructor - Only one part of the class can use a primary constructor
Partial events also require one defining and one implementing declaration:
public partial class Downloader
{
public partial event Action<string> DownloadCompleted;
}
public partial class Downloader
{
public partial event Action<string> DownloadCompleted
{
add { }
remove { }
}
}
The implementing part must include both add and remove accessors.
You can see the complete list of new features in C# 14 here.
What's New in ASP.NET Core in .NET 10
Validation Support in Minimal APIs
ASP.NET Core 10 adds built-in validation support for Minimal APIs.
This feature automatically validates data sent to your endpoints, including query parameters, headers, and request bodies.
Register validation services by calling AddValidation:
builder.Services.AddValidation();
The validation system automatically discovers types used in your Minimal API handlers and validates them using attributes from the System.ComponentModel.DataAnnotations namespace.
You can apply validation attributes directly to endpoint parameters:
app.MapPost("/products",
([Range(1, int.MaxValue)] int productId, [Required] string name) =>
{
return TypedResults.Ok(new { productId, name });
});
Record types work with validation too:
public record Product(
[Required] string Name,
[Range(1, 1000)] int Quantity);
app.MapPost("/products", (Product product) =>
{
return TypedResults.Ok(product);
});
When validation fails, the runtime automatically returns a 400 Bad Request response with details about the validation errors.
You can disable validation for specific endpoints:
app.MapPost("/products", (int productId, string name) =>
TypedResults.Ok(productId))
.DisableValidation();
You can customize error responses by implementing IProblemDetailsService and registering it in the dependency injection container.
This lets you create consistent, user-friendly error messages across your application.
JSON Patch Support in Minimal APIs
To enable JSON Patch support with System.Text.Json, install the package:
dotnet add package Microsoft.AspNetCore.JsonPatch.SystemTextJson --prerelease
Server-Sent Events (SSE)
Server-Sent Events (SSE) provide a lightweight, reliable way for ASP.NET Core apps to push continuous streams of data without the complexity of bidirectional protocols.
Server-Sent Events (SSE) is a web standard that enables a server to push real-time data to web clients over a single HTTP connection.
Unlike traditional request-response patterns where clients must repeatedly poll the server for updates, SSE allows the server to initiate communication and send data whenever new information becomes available.
For sending Server-Sent Events, you need to provide a data stream via IAsyncEnumerable<T>.
Here's a service that generates stock price updates:
public record StockPriceEvent(string Id, string Symbol, decimal Price, DateTime Timestamp);
public class StockService
{
public async IAsyncEnumerable<StockPriceEvent> GenerateStockPrices(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var symbols = new[] { "MSFT", "AAPL", "GOOG", "AMZN" };
while (!cancellationToken.IsCancellationRequested)
{
var symbol = symbols[Random.Shared.Next(symbols.Length)];
var price = Math.Round((decimal)(100 + Random.Shared.NextDouble() * 50), 2);
var id = DateTime.UtcNow.ToString("o");
yield return new StockPriceEvent(id, symbol, price, DateTime.UtcNow);
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
}
}
}
Use TypedResults.ServerSentEvents to create an endpoint that streams data:
builder.Services.AddSingleton<StockService>();
app.MapGet("/stocks", (StockService stockService, CancellationToken ct) =>
{
return TypedResults.ServerSentEvents(
stockService.GenerateStockPrices(ct),
eventType: "stockUpdate"
);
});
Clients can connect to this endpoint and receive real-time updates as they're generated.
You can read an in-depth guide on Server-Sent Events with ASP.NET Core here.
OpenAPI 3.1 Support
ASP.NET Core 10 adds support for generating OpenAPI 3.1 documents.
While the version number suggests a minor update, OpenAPI 3.1 is significant because it includes full support for JSON Schema draft 2020-12.
Key Changes in OpenAPI 3.1:
- Nullable types use a
typearray that includesnullinstead of anullable: trueproperty - Integer types (
intandlong) may appear without thetype: integerfield and use apatternfield instead, depending on your JSON serialization settings - The default OpenAPI version is now 3.1
You can explicitly set the OpenAPI version when adding OpenAPI support:
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_1;
});
YAML Format Support
ASP.NET Core now supports serving OpenAPI documents in YAML format.
YAML is more concise than JSON and supports multi-line strings, making it easier to read long descriptions.
To serve YAML documents, specify a .yaml or .yml extension:
if (app.Environment.IsDevelopment())
{
app.MapOpenApi("/openapi/{documentName}.yaml");
}
What's New in Blazor
Blazor receives several improvements in .NET 10:
- Hot Reload for Blazor WebAssembly and .NET on WebAssembly
- Environment configuration in standalone Blazor WebAssembly apps
- Performance profiling and diagnostic counters for Blazor WebAssembly
-
NotFoundPageparameter for the Blazor router - Static asset preloading in Blazor Web Apps
- Improved form validation
You can see the complete list of ASP.NET Core 10 features here.
What's New in EF Core 10
Complex Types
Complex types let you model data that belongs to an entity but doesn't have its own identity.
While entity types map to database tables, complex types can map to columns in the same table (table splitting) or to a JSON column.
Complex types enable document modeling patterns that can improve performance by avoiding JOINs and simplifying your database schema.
Table Splitting
Map a customer's addresses as complex types:
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(c => c.ShippingAddress);
b.ComplexProperty(c => c.BillingAddress);
});
Optional Complex Types
EF 10 adds support for optional complex types:
public class Customer
{
public int Id { get; set; }
public Address ShippingAddress { get; set; }
public Address? BillingAddress { get; set; }
}
JSON Mapping
Map complex types to JSON columns:
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(c => c.ShippingAddress, c => c.ToJson());
b.ComplexProperty(c => c.BillingAddress, c => c.ToJson());
});
Struct Support
Complex types support .NET structs:
public struct Address
{
public required string Street { get; set; }
public required string City { get; set; }
public required string ZipCode { get; set; }
}
LeftJoin and RightJoin Operators
LEFT JOIN is common in database queries, but previous versions of EF Core made it complicated to write.
You needed to combine SelectMany, GroupJoin, and DefaultIfEmpty in a specific way.
.NET 10 adds first-class LINQ support for LeftJoin, making these queries much simpler:
var query = context.Students
.LeftJoin(
context.Departments,
student => student.DepartmentID,
department => department.ID,
(student, department) => new
{
student.FirstName,
student.LastName,
Department = department.Name ?? "[NONE]"
});
EF 10 also supports RightJoin, which keeps all data from the second collection and only matching data from the first.
Both methods translate to the appropriate SQL JOIN operations.
Note that C# query syntax (
from x select x.Id) doesn't yet support these operators.
ExecuteUpdate for JSON Columns
While EF Core has supported JSON columns for several versions, ExecuteUpdate couldn't modify them.
EF 10 changes this by allowing you to update JSON properties in bulk operations.
Given this model with a JSON column:
public class Blog
{
public int Id { get; set; }
public BlogDetails Details { get; set; }
}
public class BlogDetails
{
public string Title { get; set; }
public int Views { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().ComplexProperty(b => b.Details, bd => bd.ToJson());
}
You can now update properties within the JSON column:
await context.Blogs.ExecuteUpdateAsync(s =>
s.SetProperty(b => b.Details.Views, b => b.Details.Views + 1));
This generates efficient SQL that updates the JSON data directly in the database.
Named Query Filters
Global query filters let you define conditions that apply to all queries for an entity type. This simplifies implementing patterns like soft deletion and multi-tenancy.
Previously, EF Core supported only one query filter per entity type.
EF 10 introduces named query filters, letting you define multiple filters and control them independently:
modelBuilder.Entity<Blog>()
.HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
.HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);
You can selectively disable specific filters in individual queries:
var allBlogs = await context.Blogs
.IgnoreQueryFilters(["SoftDeletionFilter"])
.ToListAsync();
This gives you fine-grained control over which filters apply to each query.
Regular Lambdas in ExecuteUpdateAsync
ExecuteUpdateAsync lets you perform bulk updates directly in the database without loading entities into memory.
In previous versions, you had to provide changes as expression trees, making it challenging to build dynamic updates.
EF 10 allows regular lambdas with conditional logic:
await context.Blogs.ExecuteUpdateAsync(s =>
{
s.SetProperty(b => b.Views, 8);
if (nameChanged)
{
s.SetProperty(b => b.Name, "foo");
}
});
This is much simpler than manually building expression trees and makes conditional updates straightforward.
You can see the complete list of EF Core 10 features here.
Other Changes in .NET 10
Additional resources for .NET 10:
- Breaking changes in .NET 10
- What's New in the .NET 10 SDK
- What's New in the .NET 10 runtime
- What's New in .NET libraries for .NET 10
- Performance Improvements in .NET 10
- What's New in .NET Aspire
Summary
.NET 10 and C# 14 represent a significant step forward for the .NET ecosystem.
The file-based apps feature makes C# viable for scripting scenarios that were previously dominated by Python, Node.js and JavaScript.
Extension members give you flexibility by letting you add properties to types, not just methods.
The improvements to Minimal APIs, EF Core, and ASP.NET Core make building modern web applications faster and more intuitive.
As an LTS release, .NET 10 provides a stable foundation for both new and existing applications.
Whether you're building Web APIs, desktop applications, or CLI tools, this release offers features that improve productivity and code quality.
I've been testing .NET 10 in personal projects and will begin migrating commercial projects this month.
Are you excited about .NET 10? Hit reply and let me know.
P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.
Top comments (0)