Working with large datasets can make bulk operations challenging, especially when performance matters.
When you need to delete thousands or even millions of records, the methods or packages you choose make a big difference.
In today's blog post, we'll explore how to perform bulk deletes using EF Core and Dapper.
NOTE: This post lays the groundwork for a future article, where we’ll go beyond these examples and achieve even better performance using NuGet packages such as ZZZ Projects' Bulk Operations, which takes performance to another level.
Getting Started
To get a clear picture of performance, we’ll use BenchmarkDotNet.
BenchmarkDotNet is a popular .NET library for accurate and reliable performance measurements. To learn more, check out my detailed blog post: Benchmark Code using BecnhamarkDotNet
To get started with BenchmarkDotNet, 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 BenchmarkDotNet
For this benchmark, we will use a simple Product entity:
public sealed 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; }
}
We also need rows to delete for the benchmark:
[IterationSetup]
public void IterationSetup()
{
var random = new Random(42);
var products = Enumerable.Range(1, 1000)
.Select(i => new Product(
Guid.NewGuid(),
DateTime.UtcNow,
$"Product {i}",
$"Description {i}",
random.Next()))
.ToList();
_dbContext.AddRange(products);
_dbContext.SaveChangesAsync();
}
For this example I am using IterationSetup so for each iteration we have new rows.
NOTE: For this test I am using PostgreSQL.
EF Core Remove Range
The simplest way to delete entities looks like this:
[Benchmark]
public async Task EfCoreFetchAndDelete()
{
var productsToDelete = await _dbContext.Products
.ToListAsync();
_dbContext.RemoveRange(productsToDelete);
await _dbContext.SaveChangesAsync();
}
Fetching products in memory and removing them works for a few entities but is inefficient for thousands or millions.
Using Raw SQL
One efficient way is to execute raw SQL:
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync();
await connection.ExecuteAsync($"Delete from \"Products\"");
This is very fast however we can achieve the same result using EF Core as well:
await _dbContext.Database.ExecuteSqlInterpolatedAsync(
$"Delete from \"Products\"");
Both approaches run the SQL directly on the database, bypassing the EF Core change tracker which makes them ideal for scenarios where speed and simplicity are more important than entity tracking.
EF Core ExecuteDelete
However, if you’re using EF Core and want to avoid raw SQL, there’s a cleaner and nearly as performant alternative:
await _dbContext.Products
.ExecuteDeleteAsync();
ExecuteDelete runs a bulk delete directly in the database.
NOTE: ExecuteDelete runs SQL immediately, it does not wait for SaveChangesAsync().
If you want it to execute within a transaction, wrap it manually in a transaction.
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync();
var transaction = await connection.BeginTransactionAsync();
await _dbContext.Products
.ExecuteDeleteAsync();
await transaction.CommitAsync();
Benchmark Results
To back this up with real data, here are the benchmark results:
| Method | Mean | Error | StdDev |
|-------------------------|-----------|------------|-----------|
| EfCoreFetchAndDelete | 80.719 ms | 1.6005 ms | 4.0154 ms |
| EfCoreExecuteDelete | 3.434 ms | 0.0553 ms | 0.1604 ms |
| EfCoreSql | 3.175 ms | 0.1301 ms | 0.3712 ms |
| Dapper | 3.282 ms | 0.1105 ms | 0.3170 ms |
Using raw SQL is consistently a bit faster, but ExecuteDelete remains the cleanest and safest approach overall.
Next Step
If you’re looking to push performance even further, stay tuned for the future blog posts where we’ll explore NuGet packages that take EF Core bulk delete performance to an entirely new level.
Conclusion
Dapper is amazingly fast and easy to use, although EF Core can be as fast if used properly.
With EF Core’s ExecuteDelete, it can be even easier to use while being on par with Dappers performance.
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!
