EF Core slows down on bulk work. With thousands of entities, SaveChanges() tracks every one of them and generates a separate SQL statement per row. Entity Framework Extensions replaces that path with native bulk operations that send the data in one command.
In my experience, many .NET developers (myself included) enjoy using Entity Framework in their applications. It’s an elegant, productive Object-Relational Mapper (ORM) that lets us interact with databases using C# objects and LINQ queries instead of writing raw SQL.
However, there’s a “hidden” bottleneck in every data-intensive application: the moment you need to handle bulk data with SaveChanges().
The problem
When you try to insert, update, or delete a large number of entities (5,000+ records) using the standard EF approach, you eventually hit a wall, for two reasons:
- Excessive database round-trips: every data manipulation operation is transactional, and when
SaveChanges()is called, EF generates a corresponding SQL statement for each entity. One action = one statement. So if you try to insert, update, or delete 5,000 records at once, you’ll end up with 5,000 individual statements. EF Core batches them, but that is still about 120 round trips, which is far from performant. - Resources overload: change tracking is resource-intensive.
When you add thousands of entities, the context has to monitor every property of every entity, consuming significant memory and CPU cycles.
- Memory overhead: for each entity EF stores:
- Original values
- Current values
- Tracking metadata (state information, relationship data)
- CPU cycles: EF must constantly:
- Detect changes by comparing current values to snapshots
- Maintain navigation properties
- Track entity states
- Memory overhead: for each entity EF stores:
And the change tracker works like this:
- Tracking: when an entity is added to the DbContext, the ChangeTracker begins monitoring it.
- Detection: when
SaveChanges()is called, EF first runs theDetectChanges()mechanism. This process scans all tracked entities to determine their current state by comparing their current property values with the original snapshots. - SQL generation: for every entity identified as Modified, Added, or Deleted, EF generates a separate SQL command (
INSERT,UPDATE, orDELETE). - Execution: these commands are sent to the database in batches (by default up to 42 statements per round trip on SQL Server), so thousands of rows still mean hundreds of round trips.
Entity Framework Extensions addresses both issues.
What is Entity Framework Extensions?
It’s a .NET library focused on high-performance bulk operations.
It doesn’t replace EF Core. The two work together.
The process has three steps.
In the first step, EF Extensions does not rely on the ChangeTracker; instead it uses EF’s metadata model to determine table names, identity columns, primary keys, etc. This operation has no memory cost because the metadata is cached.
The second step converts entities into a format optimized for bulk database operations for the target database (e.g. COPY for PostgreSQL or SqlBulkCopy for SQL Server). I weighed that raw path against an orchestrated one in Dapper Plus or SqlBulkCopy?.
The third step executes the bulk operation transactionally in a single command. Native bulk-loading mechanisms move the data directly from C# objects to the database.
Entity Framework Extensions in action
Setup
To follow along, you will need:
- Preferred IDE (mine is Visual Studio), but you can also use .NET Fiddle
- Latest .NET version
- A database provider. Since this library supports almost every provider, I’ll be using PostgreSQL
Also, add the following NuGet packages to your project:
- Npgsql.EntityFrameworkCore.PostgreSQL
- Microsoft.EntityFrameworkCore.Relational
- Microsoft.EntityFrameworkCore.Design
- Bogus
- Z.EntityFramework.Extensions.EFCore
Let’s start with defining entities, database setup, and creating a fake data generator.
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public DateTime OrderDate { get; set; } = DateTime.UtcNow;
public decimal TotalAmount { get; set; }
public string Status { get; set; } = "Pending";
public ICollection<OrderItem> OrderItems { get; set; } = [];
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
public Order Order { get; set; } = null!;
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql("Host=localhost;Database=EFE_DB;Username=postgres;Password=admin"));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.Run();
public class FakeDataGenerator
{
public static List<Order> PrepareOrders(int count)
{
var orderIdCounter = 0;
var orderItemIdCounter = 0;
var orderItemFaker = new Faker<OrderItem>()
.RuleFor(oi => oi.Id, _ => Interlocked.Increment(ref orderItemIdCounter))
.RuleFor(oi => oi.ProductName, f => f.Commerce.ProductName())
.RuleFor(oi => oi.Quantity, f => f.Random.Int(1, 10))
.RuleFor(oi => oi.UnitPrice, f =>
{
var price = f.Random.Decimal(5.00m, 500.00m);
return Math.Round(price, 2, MidpointRounding.AwayFromZero);
})
.RuleFor(oi => oi.TotalPrice, (f, oi) => oi.Quantity * oi.UnitPrice);
var orderFaker = new Faker<Order>()
.RuleFor(o => o.Id, _ => Interlocked.Increment(ref orderIdCounter))
.RuleFor(o => o.CustomerName, f => f.Person.FullName)
.RuleFor(o => o.OrderDate, f => f.Date.RecentOffset(365).UtcDateTime)
.RuleFor(o => o.Status, f => f.PickRandom("Pending", "Processing", "Completed", "Cancelled"))
.RuleFor(o => o.OrderItems, f =>
{
var itemCount = f.Random.Int(1, 5);
var items = orderItemFaker.Generate(itemCount);
return items;
})
.RuleFor(o => o.TotalAmount, (f, o) => o.OrderItems.Sum(oi => oi.TotalPrice));
var orders = orderFaker.Generate(count);
foreach (var order in orders)
{
foreach (var item in order.OrderItems)
{
item.OrderId = order.Id;
}
}
return orders;
}
}
Bulk insert
With one line of code you are able to add thousands of records in just one method.
app.MapPost("/api/v1/insert", async (AppDbContext db) =>
{
var orders = FakeDataGenerator.PrepareOrders(10_000);
var options = new Action<BulkOperation<Order>>(opt =>
{
opt.IncludeGraph = true;
opt.InsertIfNotExists = true;
opt.BatchSize = 1000;
});
await db.BulkInsertAsync(orders, options);
return Results.Ok(new
{
message = "Inserted 10000 orders",
count = orders.Count
});
});
I also added some additional options to the method, like:
- IncludeGraph: inserts parent entities AND all their related child entities in a single operation
- InsertIfNotExists: only inserts entities that don’t already exist in the database. It checks for duplicates based on a defined key and skips any rows that match.
- BatchSize: controls how many rows are inserted in each database round-trip
The method options are the same for the others, so I won’t duplicate them here.
For even better performance, use the BulkInsertOptimized method.
app.MapPost("/api/v1/optimized-insert", async (AppDbContext db) =>
{
var orders = FakeDataGenerator.PrepareOrders(10_000);
await db.BulkInsertOptimized(orders, options);
return Results.Ok(new
{
message = "Inserted 10000 orders in optimized way",
count = orders.Count
});
});
The two key differences between this method and BulkInsert are as follows:
- The
AutoMapOutputDirectionflag, which instructs the database not to return any values (such as identity keys) after the query is executed. - The return type. This method returns a
BulkOptimizedAnalysisclass, which provides hints and recommendations for better performance.
Bulk update
The idea is the same as BulkInsert, but here an update is performed.
app.MapPost("/api/v1/update", async (AppDbContext db) =>
{
var orders = await db.Orders.Take(100).ToListAsync();
foreach (var order in orders)
{
order.Status = "Approved";
order.TotalAmount *= 10;
}
await db.BulkUpdateAsync(orders);
return Results.Ok(new
{
message = "Updated 100 orders",
count = orders.Count
});
});
Benefits:
- Full control over the update behavior
- Avoiding loading entities into memory
- Hundreds of options
- High update speed
Bulk delete
The primary advantage is the ability to perform bulk deletions without the need to pre-fetch entities from the database, all through a single method call.
app.MapDelete("/api/v1/delete", async (AppDbContext db) =>
{
var orders = await db.Orders.Take(100).ToListAsync();
await db.BulkDeleteAsync(orders);
return Results.Ok(new
{
message = "Deleted 100 orders",
count = orders.Count
});
});
This method provides a feature set consistent with bulk updates, including extensive configuration options and better performance.
Beyond the basics
Entity Framework Extensions includes more than just Insert, Update, and Delete methods. There are several others that you may find useful.
Bulk merge
The “Add or Update” operation works as follows:
- Update existing rows that match the entity key
- Insert new rows for records that don’t exist
app.MapPost("/api/v1/merge", async (AppDbContext db) =>
{
var orders = FakeDataGenerator.PrepareOrders(1000);
await db.BulkMergeAsync(orders);
var totalCount = await db.Orders.CountAsync();
return Results.Ok(new
{
message = "Upserted 1000 orders",
count = totalCount
});
});
This method replaces multiple SELECT, INSERT, and UPDATE queries with a single, high-performance MERGE command, which can be extended with various options.
Bulk synchronize
This is an “all-in-one” method because it handles everything in a single operation:
- Rows that match the entity key are updated
- Rows that exist in the source but not in the database are inserted
- Rows that exist in the database but not in the source are deleted
app.MapPost("/api/v1/synchronize", async (AppDbContext db) =>
{
var products = FakeDataGenerator.PrepareOrders(50);
var options = new Action<BulkOperation<Order>>(options =>
{
options.ColumnSynchronizeDeleteKeySubsetExpression = p => p.OrderItems;
options.SynchronizeSoftDeleteFormula = "\"IsActive\" = false";
});
await db.BulkSynchronizeAsync(products, options);
var totalCount = await db.Orders.CountAsync();
return Results.Ok(new
{
message = "Synchronized products - mirrored source list to database",
totalInDb = totalCount,
note = "Orders not in the list were deleted"
});
});
Characteristic options for this method include:
ColumnSynchronizeDeleteKeySubsetExpression: synchronizes a table subset instead of the whole tableSynchronizeSoftDeleteFormula: marks rows as deleted instead of permanent removal
To prevent accidental full-table deletions,
BulkSynchronizeis a no-op for an empty list. This is vital when usingColumnSynchronizeDeleteKeySubsetExpression, as a subset cannot be determined without specific items.
BulkSaveChanges
The most critical method in EF Core gets the same treatment:
app.MapPost("/api/v1/save-changes", async (AppDbContext db) =>
{
var newCustomers = FakeDataGenerator.PrepareOrders(50);
db.Orders.AddRange(newCustomers);
var existingCustomers = await db.Orders.Take(50).ToListAsync();
existingCustomers.ForEach(c => c.TotalAmount *= 10);
var toDelete = await db.Orders.Skip(100).Take(20).ToListAsync();
db.Orders.RemoveRange(toDelete);
await db.BulkSaveChangesAsync();
return Results.Ok(new
{
message = "BulkSaveChanges processed Add, Update, and Delete",
added = 50,
updated = 50,
deleted = 20
});
});
BulkSaveChanges mirrors the SaveChanges method but uses bulk operations for all ChangeTracker entries (Added, Modified, Deleted) to improve performance.
There is also a method that improves read performance by using a SQL JOIN instead of a large IN statement. I took that one apart in Where vs. WhereBulkContains, and there is more on the WhereBulkContains page.
You can further customize the behavior of these methods using the available bulk and column options.
And if your goal is to completely avoid loading data into the application context, you also have batch operations at your disposal.
Summary
If your application is data-intensive and needs better performance, this library is a strong fit.
It adds batch operations directly to your existing DbContext, which improves performance and reduces your application’s memory footprint.
Disclaimer: for licensing information, please refer to the licensing page.