Entity Framework Core is an amazing ORM but when it comes to bulk operations, it can be slow.
Standard EF Core methods work fine for small to medium datasets but as the number of records grows, performance can degrade significantly.
In today's blog post, we'll explore Entity Framework Extensions, a powerful library that provides optimized bulk operations for EF Core, improving performance when working with large datasets.
EF Extensions
Entity Framework Extensions is a commercial library that extends EF Core with high-performance bulk operations.
It offers a wide range of bulk methods that execute operations directly in the database, bypassing EF Core's change tracker and generating optimized SQL statements.
To get started with Entity Framework Extensions, you need to install the NuGet package. You can do this via the NuGet Package Manager or by running the following command in the Package Manager Console:
Install-Package Z.EntityFramework.Extensions
Once installed, you'll have access to extension methods on your DbContext that enable bulk operations.
For this blog post, we'll use a simple Product entity with a relationship to ProductReview:
public class Product
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public List<ProductReview> Reviews { get; set; }
}
public class ProductReview
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public Product Product { get; set; }
public string Author { get; set; }
public string Content { get; set; }
public int Rating { get; set; }
public DateTime CreatedAt { get; set; }
}
BulkInsert
BulkInsert is one of the most commonly used bulk operations. Instead of calling AddRange (or even worse, foreach Add) and SaveChanges, it generates optimized SQL that inserts all records in a single database roundtrip:
products.MapPost("/bulk-insert", async (ApplicationDbContext db) =>
{
var items = ProductFactory.Generate(50).ToList();
await db.BulkInsertAsync(items);
return Results.Ok(new { inserted = items.Count });
});
BulkInsert doesn't use EF Core's change tracker, which means it won't automatically set navigation properties or track entities. However, it will populate primary keys if they're database-generated.
IncludeGraph
The IncludeGraph option tells BulkInsert to also insert related entities in the correct order:
products.MapPost("/bulk-insert-graph", async (ApplicationDbContext db) =>
{
var items = ProductFactory.Generate(10).ToList();
await db.BulkInsertAsync(items, options => { options.IncludeGraph = true; });
return Results.Ok(new { inserted = items.Count });
});
With IncludeGraph enabled, if your Product entities have Reviews populated, BulkInsert will first insert the Products, then insert the Reviews.
BulkUpdate
BulkUpdate generates optimized SQL that updates all records in a single database operation, similar to BulkInsert:
products.MapPut("/bulk-update", async (ApplicationDbContext db) =>
{
var items = await db.Products.Take(20).ToListAsync();
foreach (var p in items)
{
p.Price += 10;
p.ModifiedAt = DateTime.UtcNow;
}
await db.BulkUpdateAsync(items);
return Results.Ok(new { updated = items.Count });
});
It identifies entities by their primary key and updates all modified properties.
BulkDelete
BulkDelete deletes multiple entities efficiently, much faster than calling RemoveRange (or even worse, foreach Remove) and SaveChanges for each entity:
products.MapDelete("/bulk-delete", async (ApplicationDbContext db) =>
{
var items = await db.Products.Take(20).ToListAsync();
await db.BulkDeleteAsync(items);
return Results.Ok(new { deleted = items.Count });
});
BulkDelete identifies entities to delete by their primary key, so you only need to provide entities with their IDs populated.
BulkSaveChanges
BulkSaveChanges is a replacement for the standard SaveChanges method. It works with EF Core's change tracker but executes operations in bulk, useful when you want to keep change tracking while benefiting from bulk performance:
products.MapPost("/bulk-save-changes", async (ApplicationDbContext db) =>
{
var items = ProductFactory.Generate(20).ToList();
db.Products.AddRange(items);
await db.BulkSaveChangesAsync();
return Results.Ok(new { saved = items.Count });
});
It analyzes the change tracker and groups operations by type (inserts, updates, deletes), then executes them in bulk.
DeleteFromQuery
DeleteFromQuery efficiently deletes records by translating your LINQ filter into a single SQL DELETE operation without loading any entities into memory:
products.MapDelete("/delete-from-query", async (ApplicationDbContext db) =>
{
var count = await db.Products
.Where(p => p.Price < 10)
.DeleteFromQueryAsync();
return Results.Ok(new { deleted = count });
});
UpdateFromQuery
Similar to DeleteFromQuery, UpdateFromQuery performs batch updates by converting your LINQ filter and update expression into a single SQL UPDATE statement executed directly on the database, without loading any entities.
products.MapPut("/update-from-query", async (ApplicationDbContext db) =>
{
var count = await db.Products
.Where(p => p.Price < 200)
.UpdateFromQueryAsync(p => new Product
{
Price = p.Price + 5,
ModifiedAt = DateTime.UtcNow
});
return Results.Ok(new { updated = count });
});
BulkMerge
BulkMerge performs an upsert by using the primary key to insert new entities and update existing ones in a single operation:
products.MapPost("/bulk-merge", async (ApplicationDbContext db) =>
{
var items = ProductFactory.Generate(30).ToList();
await db.BulkMergeAsync(items);
return Results.Ok(new { merged = items.Count });
});
BulkSynchronize
BulkSynchronize goes a step further than BulkMerge by also deleting entities that exist in the database but not in your collection, ensuring your database exactly matches your in-memory collection:
products.MapPost("/bulk-synchronize", async (ApplicationDbContext db) =>
{
var items = ProductFactory.Generate(10).ToList();
await db.BulkSynchronizeAsync(items);
return Results.Ok(new { synchronized = items.Count });
});
WhereBulkContains
WhereBulkContains efficiently queries entities by a collection of IDs, generating optimized SQL instead of using multiple OR conditions or Contains with a large list:
products.MapGet("/bulk-contains", async (ApplicationDbContext db) =>
{
var ids = await db.Products.Select(p => p.Id).Take(10).ToListAsync();
var result = await db.Products.WhereBulkContains(ids).ToListAsync();
return Results.Ok(result);
});
WhereBulkContains performs well even with thousands of IDs.
Conclusion
Entity Framework Extensions delivers highly optimized bulk operations that drastically improve performance and reduce memory usage when handling large datasets.
Bulk insert, update, delete, merge and synchronize are bypassing EF Core's change tracker and execute directly against database.
BulkSaveChanges works with EF Core's change tracker but executes operations in bulk.
DeleteFromQuery and UpdateFromQuery perform batch updates by converting LINQ into a single SQL statement executed directly on the database, without loading any entities.
WhereBulkContains efficiently queries entities by a collection of IDs.
Although it’s a commercial library, the speed and productivity gains often justify the investment.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
