This content originally appeared on Telerik Blogs and was authored by Assis Zang
Entity Framework Core has been improving yearly, bringing tools to simplify data access and management in .NET applications. Check out this post with 10 of the main new features in EF 9 for ASP.NET Core.
Entity Framework Core—EF Core—is the main object relational model (ORM) maintained by Microsoft. It has been continuously improved to meet the demands of developers and the needs of modern applications. Some of the main aspects include performance, developer productivity, scalability, mapping flexibility and others.
In this post, we will look at the 10 main new features of EF 9, which arrived with the release of .NET 9 in November 2024.
Sample Code
You can access the project with all the example code covered in the post in this GitHub repository: EF 9 news source code.
1. New LINQ Queries
LINQ (Language Integrated Query) is a .NET feature that allows you to query different data sources directly in C# code, using a declarative syntax integrated into the language. In Version 9 of EF Core, we can highlight the following improvements, regarding LINQ query methods.
1.1 GroupBy Support for Complex Types
Until EF 8, the GroupBy
extension method was only available for properties. Now you can group complex types, such as classes and records.
Note the example below:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Phone { get; set; }
public string Region { get; set; }
public required Address CustomerAddress { get; set; }
}
[ComplexType]
public record class Address(string Street, int Number, string City, string Country, string PostCode);
public async Task ComplexTypesGroupByAsync()
{
var groupedAddresses = await _dbContext.Customers
.GroupBy(b => b.CustomerAddress)
.Select(g => new { g.Key, Count = g.Count() })
.ToListAsync();
}
Here we have the Customer class that uses the Address record that is declared as a complex type, so in the ComplexTypesGroupByAsync()
method, the addresses are grouped by the Address
object (complex type). In this case, the key is all the properties of the object, and the quantity is the number of records present in that group.
The output in SQL in SQLServer is as follows:
SELECT
[c].[CustomerAddress_City],
[c].[CustomerAddress_Country],
[c].[CustomerAddress_Number],
[c].[CustomerAddress_PostCode],
[c].[CustomerAddress_Street],
COUNT(*) AS [Count]
FROM [Customers] AS [c]
GROUP BY
[c].[CustomerAddress_City],
[c].[CustomerAddress_Country],
[c].[CustomerAddress_Number],
[c].[CustomerAddress_PostCode],
[c].[CustomerAddress_Street]
1.2 ExecuteUpdate Support for Complex Types
Another method now available for complex types is ExecuteUpdate
. This method is used to perform bulk updates directly on the database, without loading the entities into memory, thus achieving better performance compared to the traditional approach of loading, modifying and saving entities.
Note the code below:
public async Task ExecuteUpdateAddress(Address newAddress)
{
await _dbContext.Customers
.Where(e => e.Region == "USA")
.ExecuteUpdateAsync(s => s.SetProperty(b => b.CustomerAddress, newAddress));
}
Here we are telling EF to update all customer addresses in the USA region with the values received by the new address. In this case, the SQL translation is as follows:
UPDATE [c] SET
[c].[CustomerAddress_City] = @__complex_type_newAddress_0_City,
[c].[CustomerAddress_Country] = @__complex_type_newAddress_0_Country,
[c].[CustomerAddress_Number] = @__complex_type_newAddress_0_Number,
[c].[CustomerAddress_PostCode] = @__complex_type_newAddress_0_PostCode,
[c].[CustomerAddress_Street] = @__complex_type_newAddress_0_Street
FROM [Customers] AS [c]
WHERE [c].[Region] = N'USA'
Previously, it was necessary to manually list the different properties of the complex type when calling the ExecuteUpdate
method, but now this is no longer necessary.
2. Improvement in Round-trip Queries
EF 9 has received an improvement to save database round trips.
Consider the following LINQ query:
public async Task<IEnumerable<CustomerWithCount>> GetUSCustomers()
{
var usCustomers = _dbContext.Customers.Where(c => c.Region.Contains("USA"));
return await usCustomers
.Where(c => c.Id > 1)
.Select(c => new CustomerWithCount{ Customer = c, TotalCount = usCustomers.Count() })
.Skip(2).Take(10)
.ToArrayAsync();
}
In EF 8, two round-trip queries are executed, which are translated to SQL Server as follows:
– round-trip 1
SELECT COUNT(*)
FROM [Customers] AS [c]
WHERE [c].[Region] LIKE N'%USA%'
– round-trip 2
SELECT
[c].[Id],
[c].[Name],
[c].[Phone],
[c].[Region],
[c].[CustomerAddress_City],
[c].[CustomerAddress_Country],
[c].[CustomerAddress_Number],
[c].[CustomerAddress_PostCode],
[c].[CustomerAddress_Street]
FROM [Customers] AS [c]
WHERE [c].[Region] LIKE N'%USA%' AND [c].[Id] > 1
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
In EF 9, the IQueryable
on usCustomers
is inlined, so a single database round trip is made:
SELECT
[c].[Id],
[c].[Name],
[c].[Phone],
[c].[Region],
[c].[CustomerAddress_City],
[c].[CustomerAddress_Country],
[c].[CustomerAddress_Number],
[c].[CustomerAddress_PostCode],
[c].[CustomerAddress_Street],
(SELECT COUNT(*) FROM [Customers] AS [c0]
WHERE [c0].[Region] LIKE N'%USA%') AS [TotalCount]
FROM [Customers] AS [c]
WHERE [c].[Region] LIKE N'%USA%' AND [c].[Id] > 1
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
This EF 9 approach saves resources because it only makes one round trip to the database, instead of two as in EF 8.
3. Read-only Primitive Collections
Support for array mapping and mutable lists of primitive types was introduced in EF 8, but this feature has been expanded in EF 9 to also include read-only collections/lists.
This feature helps deal with scenarios where collections of primitive types such as string, int or bool are prevented from being modified, something that previously required workarounds.
Note the example below:
public class PartyEvent
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public ReadOnlyCollection<int> EventDays { get; set; }
public DateTime CreatedDate { get; set; }
}
The EventDays
property is of type ReadOnlyCollection<int>
in this case it cannot be modified, however, it can be used normally as shown in the code below:
public async Task ProcessReadOnlyPrimitiveCollections()
{
var today = DateTime.Today;
var pastEvents = await _dbContext.PartyEvents
.Where(e => e.CreatedDate < today)
.Select(p => new
{
Id = Guid.NewGuid(),
Name = p.Name,
Count = p.EventDays.Count(d => p.EventDays.Contains(d)),
TotalCount = p.EventDays.Count
})
.ToListAsync();
}
Here we are using the values from the EventDays
list, but without modifying them. If we try to modify the list, we will receive the following error from the Visual Studio interpreter:
var initialList = new List<int> { 1, 2, 3 };
var readWriteCollection = new List<int>(initialList);
var readOnlyCollection = new ReadOnlyCollection<int>(initialList);
readWriteCollection.Add(4);
readOnlyCollection.Add(4);
Note that collections of the ReadOnlyCollection
type do not have the Add()
extension method and others, precisely so that they are not modified.
4. More Efficient Queries
Up until Version 8, EF Core sometimes produced SQL with elements that were not really needed. EF 9 removes most of these elements, producing more compact and, in some cases, more efficient SQL code.
4.1 Removing Unnecessary jJoin
Note the example below:
public class Post
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Body { get; set; }
public List<Author> Authors { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
}
public class Author
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
public class TopAuthor : Author
{
public int TotalPosts { get; set; }
}
If we run the following query to get all Posts with at least one Author:
var postsWithAuthors = await _dbContext.Posts.Where(p => p.Authors.Any()).ToListAsync();
EF 8 would generate the following SQL:
SELECT
[p].[Id],
[p].[Body],
[p].[CreatedDate],
[p].[Title],
[p].[UpdatedDate]
FROM [Posts] AS [p]
WHERE EXISTS (
SELECT 1
FROM [Author] AS [a]
LEFT JOIN [TopAuthor] AS [t] ON [a].[Id] = [t].[Id]
WHERE [p].[Id] = [a].[PostId])
Note that there is a LEFT JOIN
with TopAuthor
which for the query above becomes unnecessary.
EF 9 results in a polished SQL without the join:
SELECT
[p].[Id],
[p].[Body],
[p].[CreatedDate],
[p].[Title],
[p].[UpdatedDate]
FROM [Posts] AS [p]
WHERE EXISTS (
SELECT 1
FROM [Author] AS [a]
WHERE [p].[Id] = [a].[PostId])
4.2 Removing Unnecessary Column
Similar to the previous example, the query below is now optimized in EF 9.
int recentPostQuantity = await _dbContext.Posts
.Where(p => p.CreatedDate >= DateTime.UtcNow)
.Take(6)
.CountAsync();
In EF 8, the generated SQL is as follows:
SELECT COUNT(*)
FROM (
SELECT TOP(@__p_0) [p].[Id]
FROM [Posts] AS [p]
WHERE [p].[CreatedDate] >= GETUTCDATE()
) AS [p0]
Note that the [p].[Id]
column is used in the query, even though it is not necessary, since the upper select only counts records.
In EF 9 this has been improved, the projection is now empty. It may not seem like much, but depending on the scenario, it can simplify the SQL considerably.
SELECT COUNT(*)
FROM (
SELECT TOP(@__p_0) 1 AS empty
FROM [Posts] AS [p]
WHERE [p].[CreatedDate] >= GETUTCDATE()
) AS [p0]
5. Improvement for Queries that Use != 0
In EF 8, the following LINQ query uses the SQL COUNT
function:
var postsWithAuthors = await _dbContext.Posts
.Where(b => b.Authors.Count > 0)
.ToListAsync();
SELECT
[p].[Id],
[p].[Body],
[p].[CreatedDate],
[p].[Title],
[p].[UpdatedDate]
FROM [Posts] AS [p]
WHERE (
SELECT COUNT(*)
FROM [Authors] AS [a]
WHERE [a].[PostId] = [p].[Id]
) > 0
EF 9 generates a more efficient SQL translation using EXISTS
:
SELECT
[p].[Id],
[p].[Body],
[p].[CreatedDate],
[p].[Title],
[p].[UpdatedDate]
FROM [Posts] AS [p]
WHERE EXISTS (
SELECT 1
FROM [Author] AS [a]
WHERE [p].[Id] = [a].[PostId])
6. Order and OrderDescending Operators
EF 9 has simplified the conversion of the Order
and OrderDescending
sort operations. They work similarly to the traditional OrderBy
/OrderByDescending
, but instead of using an argument passed as a parameter, they apply the default ordering. In the case of entities, the ordering is done based on primary key values. And for other types, the ordering is done based on their values.
Note the example below:
var orderedPostsWithAuthors = await _dbContext.Posts
.Order()
.Select(x => new
{
x.Title,
OrderedAuthors = x.Authors.OrderDescending().ToList(),
OrderedAuthorName = x.Authors.Select(xx => xx.Name).Order().ToList()
})
.ToListAsync();
Here we are using the new Order
and OrderDescending
operators, which will produce the following SQL:
SELECT
[p].[Title],
[p].[Id],
[a].[Id],
[a].[Email],
[a].[Name],
[a].[PostId],
[a0].[Name],
[a0].[Id]
FROM [Posts] AS [p]
LEFT JOIN [Author] AS [a] ON [p].[Id] = [a].[PostId]
LEFT JOIN [Author] AS [a0] ON [p].[Id] = [a0].[PostId]
ORDER BY [p].[Id], [a].[Id] DESC, [a0].[Name]
The same ordering could be done as follows using OrderBy
and OrderByDescending
:
var orderedByPostsWithAuthors = await _dbContext.Posts
.OrderBy(x => x.Title)
.Select(x => new
{
x.Title,
OrderedAuthors = x.Authors.OrderByDescending(a => a.Name).ToList(),
OrderedAuthorName = x.Authors.Select(a => a.Name).OrderBy(n => n).ToList()
})
.ToListAsync();
An important point is that the Order
and OrderDescending
methods are only compatible with collections of entities, complex types or scalars. For more complex projections such as collections of anonymous types containing several properties, for example, these methods will not work.
7. The Logical Negation Operator (!) Is Now More Efficient
Queries that use the logical negation operator !
have a more efficient translation in EF 9. Note the example below:
var newProducts = await _dbContext.Products
.Select(b => !(b.Id > 10 ? false : true))
.ToListAsync();
The result in EF 8 is translated into nested CASE blocks:
SELECT CASE
WHEN CASE
WHEN [p].[Id] > 10 THEN CAST(0 AS bit)
ELSE CAST(1 AS bit)
END = CAST(0 AS bit) THEN CAST(1 AS bit)
ELSE CAST(0 AS bit)
END
FROM [Products] AS [p]
In EF 9, nesting has been removed:
SELECT CASE
WHEN [p].[Id] > 10 THEN CAST(1 AS bit)
ELSE CAST(0 AS bit)
END
FROM [Products] AS [p]
8. HierarchyId in SQL Server
SQL Server’s HierarchyId
was added in EF 8 and has been improved in EF 9. A new method has been added to make it easier to create new child nodes in a tree structure.
Note the example below.
//Person class
public class Person
{
public Person(HierarchyId pathFromPatriarch, string name)
{
PathFromPatriarch = pathFromPatriarch;
Name = name;
}
public int Id { get; set; }
public string Name { get; set; }
public HierarchyId PathFromPatriarch { get; set; }
}
//Inserting a record with the name John and HierarchyId = /4/1/3/1/
public async Task SeedPerson()
{
await _dbContext.Persons.AddAsync(new Person(HierarchyId.Parse("/4/1/3/1/"), "John"));
await _dbContext.SaveChangesAsync();
}
//Retrieving the record with the name John
var john = await _dbContext.Persons.SingleAsync(p => p.Name == "John");
Here we are filtering on a person whose name is John. We can use this HierarchyId
property to create child nodes simply, without any explicit character manipulation:
var child1 = new Person(HierarchyId.Parse(john.PathFromPatriarch, 1), "Doe");
var child2 = new Person(HierarchyId.Parse(john.PathFromPatriarch, 2), "Smith");
In this case, John has a HierarchyId of /4/1/3/1/
, child1 will get the HierarchyId /4/1/3/1/1/1/1/
and child2 will get the HierarchyId /4/1/3/1/2/
.
Another possibility is to create a node between these two children, in which case an additional sublevel can be used:
var child1b = new Person(HierarchyId.Parse(john.PathFromPatriarch, 1, 5), "Johnson");
This will create a node with a HierarchyId of /4/1/3/1/1.5/
, with 1.5
, which puts it between child1 and child2.
9. New Propagation Methods UseSeeding and UseAsyncSeeding
EF 9 has added new methods to populate the database with initial data, namely UseSeeding
and UseAsyncSeeding
.
These methods aim to unify all the data propagation code in one place. In addition, both methods are protected by the migration lock mechanism to avoid concurrency issues.
Below is an example of how to use the new methods. Note that checking for the existence of records is done manually through a simple query:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSeeding((context, _) =>
{
var testPerson = context.Set<Person>().FirstOrDefault(p => p.Name == "Test_Person");
if (testPerson == null)
{
context.Set<Person>().Add(new Person(HierarchyId.Parse("/1/"), "Test_Person"));
context.SaveChanges();
}
})
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var testPerson = await context.Set<Person>().FirstOrDefaultAsync(p => p.Name == "Test_Person", cancellationToken);
if (testPerson == null)
{
context.Set<Person>().Add(new Person(HierarchyId.Parse("/1/"), "Test_Person"));
await context.SaveChangesAsync(cancellationToken);
}
});
10. Azure Cosmos DB: More Efficient Queries with Partition Keys and Document IDs
When a document is stored in an Azure Cosmos DB database, it is assigned a unique ID. Another feature available in Azure Cosmos DB is that each document can contain a “partition key” that determines the logical partitioning of the data, providing efficient scaling.
In EF 9, the provider received improvements to identify partition key comparisons in LINQ queries and extract them to ensure that queries are sent only to the relevant partition, greatly improving performance. This is because the query does not use all the available data, but only that which is part of the partition related to the query.
Note the query below:
var books = await _dbContext.Books
.Where(b => b.PartitionKey == "someValue" && b.Title.StartsWith("R"))
.ToListAsync();
In this query, the Cosmos DB provider automatically recognizes the comparison on PartitionKey
. In the logs, we can see the following:
Executed ReadNext (132.5782 ms, 2.6 RU) ActivityId='1abfdd11-e8b9-45dc-a788-74e11094a119',
Container='mytest',
Partition='["someValue"]',
Parameters=[]
SELECT VALUE c
FROM root c
WHERE STARTSWITH(c["Name"], "R")
In previous versions, the comparison was left in the WHERE clause using the PartitionKey
, this caused the query to ignore the relevant partition and execute the query on all partitions resulting in increased costs and reduced performance.
Now in EF 9, this comparison has been deprecated and is used to execute the query only on the relevant partition.
Another important point is that if the query has an ID for the document and does not include any other query operations, the provider may apply an additional optimization. Note the query below, passing the book ID:
var bookById = await _dbContext.Books
.Where(b => b.PartitionKey == "someValue" && b.Id == 1)
.SingleAsync();
If we look at the logs, we see the following:
Executed ReadItem (82 ms, 1 RU) ActivityId='304a1a63-8337-42a6-a553-8213598ef662',
Container='mytest',
Id='1',
Partition='["someValue"]'
Notice that no SQL query was sent. Instead, the provider performs a point read (ReadItem API), which directly fetches the document using the partition key and ID.
This is the most efficient and cost-effective type of read possible to perform on Azure Cosmos DB.
Conclusion
EF 9 brought many improvements to ASP.NET Core, many of which focus on improving the performance of database queries, while others bring more options for dealing with everyday tasks, such as database and table migrations, and record ordering, among others.
Whether you’re just starting out or already using EF Core, I recommend trying out these new features and evaluating how they can add value to your project.
In this post, we covered 10 of the main new features in EF 9. For a more comprehensive overview, check out the full list of changes and improvements in the official documentation: What’s New in EF 9.
This content originally appeared on Telerik Blogs and was authored by Assis Zang