10 New Features in EF 9



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);

Read-only collection error

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