This blog is presented as a part of C# Advent 2024. Follow the link to check out the rest of the excellent C# and .NET content coming out in 2 blogs per day between December 1 - 25.
Every time I decide which ORM to use in a project, I find myself personally conflicted between Dapper and Entity Framework (EF). Both are incredibly powerful ORMs and excel in the roles they were designed for, albeit in slightly different ways. Both promise their own advantages and disadvantages.
Dapper is renowned for its speed and simplicity. As long as you write efficient SQL queries, the rest is straightforward. On the other hand, EF competes by offering a convenient and modern approach to writing queries using LINQ—a style many developers love and frequently use. It also provides robust tools for managing database versioning (migrations), though sometimes at the cost of speed and memory usage. I wanted to know, though, is that still the case?
With every new release of .NET or Dapper, I eagerly explore what’s new in each. Has Dapper widened its speed advantage, or has EF closed the gap? Are there new features that could sway the decision for our next project? The release of .NET 9 brings a host of improvements. If you’re interested in learning more, check out Trailhead’s recent podcast and blog on the latest features in .NET 9!
Below I review the performance of EF Core 9, the newest version of Entity Framework released with .NET 9, and compare it to the perennial favorite in performance, Dapper. I think you will be pleasantly surprised by the results like I was.
The Benchmark Tests
I created three tests to help me benchmark the most common CRUD tasks in a software application accessing a database: reading, inserting, and updating:
- Get Benchmark: Single or multiple entities
- Insert Benchmark: Single or multiple entities
- Update Benchmark: Single entity
Test Project
I created a new .NET 9 Console Application and incorporated the following essential packages:
- BenchmarkDotNet: For benchmarking and testing.
- Bogus: To generate realistic test data.
- Dapper: A simple object mapper for .NET.
- Microsoft.EntityFrameworkCore.SqlServer: Entity Framework Core provider for SQL Server.
- Microsoft.EntityFrameworkCore.Design: Design-time tools for EF Core.
Database
For my tests, I utilized the ever-popular AdventureWorks database without any optimizations or adjustments—straight out of the box—to ensure fair comparisons.You can download the AdventureWorks sample database from Microsoft’s GitHub repository.
Code
I split the benchmarks into Get, Insert, and Update to isolate each test and run them independently. I also created a simple AppDbContext to represent the model configuration used by Entity Framework.
Additionally, I added a DbFactory a class whose sole purpose is to provide database connections for both EF and Dapper simultaneously. Here’s how that class looks:
public static class DBFactory
{
public static async Task<IDbConnection> CreateConnectionAsync()
{
var connectionString = "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=AdventureWorksDW2019";
var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
return connection;
}
}
Test 1: Get a List of Entities
Retrieve a simple list of entities based on a specified WHERE condition.
[Benchmark]
public async Task<IList<FactResellerSale>> EFCore_To_List_Raw()
{
return await _dbContext.FactResellerSales
.FromSqlRaw("SELECT * FROM FactResellerSales WHERE OrderDateKey = {0}", new SqlParameter("@OrderDateKey", _orderDateKey))
.ToListAsync();
}
[Benchmark]
public async Task<IList<FactResellerSale>> EFCore_To_List_LINQ()
{
return await _dbContext.FactResellerSales
.Where(frs => frs.OrderDateKey == _orderDateKey)
.ToListAsync();
}
[Benchmark]
public async Task<IList<FactResellerSale>> Dapper_To_List()
{
var qry = await _dbConnection.QueryAsync<FactResellerSale>(
sql: "SELECT * FROM FactResellerSales WHERE OrderDateKey = @OrderDateKey", new { OrderDateKey = _orderDateKey });
return qry.ToList();
}
As you can see I am retrieving entities based on OrderDateKey. In this database, there are roughly 14,000 items, making it a fair test.
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| EFCore_To_List_Raw | 5.861 ms | 0.0777 ms | 0.0727 ms | 930.7 KB |
| EFCore_To_List_LINQ | 5.862 ms | 0.1122 ms | 0.2214 ms | 927.56 KB |
| Dapper_To_List | 5.643 ms | 0.0615 ms | 0.0575 ms | 1460.89 KB |
Key Takeaways
It seems that EF has come incredibly close to matching Dapper in speed. The measured difference was minimal—5.862 ms for EF compared to 5.643 ms for Dapper—making EF only about 1.04x slower.
Surprisingly, Dapper used more memory—1460.89 KB compared to 930.7 KB for EF—making Dapper approximately 1.57x more memory-intensive. This was unexpected and might be due to the large number of columns being retrieved from the database.
Still, these results demonstrate that EF is continuously improving, especially with the advancements brought by .NET 9.
Test 2: Get a Single Entity
Retrieve a single entity based on a specified WHERE condition.
[Benchmark]
public async Task<FactResellerSale> Dapper_GetById_FirstAsync()
{
var qry = await _dbConnection.QueryFirstAsync<FactResellerSale>(
sql: """
SELECT TOP 1 *
FROM dbo.FactResellerSales AS frs
WHERE 1 = 1
AND frs.SalesOrderNumber = @SalesOrderNumber
AND frs.SalesOrderLineNumber = @SalesOrderLineNumber
""", new { SalesOrderNumber = _salesOderNumber, SalesOrderLineNumber = _salesOrderLineNumber });
return qry;
}
[Benchmark]
public async Task<FactResellerSale?> Dapper_GetById_FirstOrDefaultAsync()
{
var qry = await _dbConnection.QueryFirstOrDefaultAsync<FactResellerSale>(
sql: """
SELECT TOP 1 *
FROM dbo.FactResellerSales AS frs
WHERE 1 = 1
AND frs.SalesOrderNumber = @SalesOrderNumber
AND frs.SalesOrderLineNumber = @SalesOrderLineNumber
""", new { SalesOrderNumber = _salesOderNumber, SalesOrderLineNumber = _salesOrderLineNumber });
return qry;
}
[Benchmark]
public async Task<FactResellerSale> Dapper_GetById_SingleAsync()
{
var qry = await _dbConnection.QuerySingleAsync<FactResellerSale>(
sql: """
SELECT TOP 1 *
FROM dbo.FactResellerSales AS frs
WHERE 1 = 1
AND frs.SalesOrderNumber = @SalesOrderNumber
AND frs.SalesOrderLineNumber = @SalesOrderLineNumber
""", new { SalesOrderNumber = _salesOderNumber, SalesOrderLineNumber = _salesOrderLineNumber });
return qry;
}
[Benchmark]
public async Task<FactResellerSale> EfCore_GetById_By_Qry()
{
return await _dbContext.FactResellerSales
.FromSqlRaw("""
SELECT TOP 1 *
FROM dbo.FactResellerSales AS frs
WHERE 1 = 1
AND frs.SalesOrderNumber = {0}
AND frs.SalesOrderLineNumber = {1}
""",
new SqlParameter("@SalesOrderNumber", _salesOderNumber),
new SqlParameter("@SalesOrderLineNumber", _salesOrderLineNumber))
.FirstAsync();
}
[Benchmark]
public async Task<FactResellerSale> EfCore_GetById_By_FirstAsync()
{
return await _dbContext
.FactResellerSales
.FirstAsync(frs => frs.SalesOrderNumber == _salesOderNumber && frs.SalesOrderLineNumber == _salesOrderLineNumber);
}
[Benchmark]
public async Task<FactResellerSale?> EfCore_GetById_By_FirstOrDefaultAsync()
{
return await _dbContext
.FactResellerSales
.FirstOrDefaultAsync(frs => frs.SalesOrderNumber == _salesOderNumber && frs.SalesOrderLineNumber == _salesOrderLineNumber);
}
[Benchmark]
public async Task<FactResellerSale> EfCore_GetById_By_SingleAsync()
{
return await _dbContext.FactResellerSales
.Where(frs => frs.SalesOrderNumber == _salesOderNumber && frs.SalesOrderLineNumber == _salesOrderLineNumber)
.SingleAsync();
}
I am testing the most commonly used methods with EF and Dapper: First, FirstOrDefault, and Single. These are widely utilized in everyday scenarios. I decided to exclude SingleOrDefault since I believe its behavior is sufficiently covered by FirstOrDefault.
Additionally, for EF Core, I wanted to test FromSqlRaw to compare its performance against raw SQL queries. This provides a more comprehensive evaluation of how EF handles raw SQL in comparison to Dapper.
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| Dapper_GetById_FirstAsync | 1.166 ms | 0.0224 ms | 0.0230 ms | 13.15 KB |
| Dapper_GetById_FirstOrDefaultAsync | 1.174 ms | 0.0212 ms | 0.0297 ms | 13.15 KB |
| Dapper_GetById_SingleAsync | 1.137 ms | 0.0146 ms | 0.0136 ms | 13.25 KB |
| EfCore_GetById_By_Qry | 1.213 ms | 0.0241 ms | 0.0226 ms | 28.55 KB |
| EfCore_GetById_By_FirstAsync | 1.200 ms | 0.0176 ms | 0.0156 ms | 19.97 KB |
| EfCore_GetById_By_FirstOrDefaultAsync | 1.219 ms | 0.0237 ms | 0.0273 ms | 19.98 KB |
| EfCore_GetById_By_SingleAsync | 3.543 ms | 0.0444 ms | 0.0415 ms | 21.14 KB |
| Test | Mean (ms) | Dapper vs. EF Core (Time) | Memory (KB) | Dapper vs. EF Core (Memory) |
|---|---|---|---|---|
| GetById – FirstAsync | 1.200 vs. 1.166 | EF ~1.03x slower | 19.97 vs. 13.15 | EF ~1.52x more memory |
| GetById – FirstOrDefaultAsync | 1.219 vs. 1.174 | EF ~1.04x slower | 19.98 vs. 13.15 | EF ~1.52x more memory |
| GetById – SingleAsync | 3.543 vs. 1.137 | EF ~3.12x slower | 21.14 vs. 13.25 | EF ~1.60x more memory |
Key Takeaways
Dapper consistently outperforms EF Core in execution speed, but the difference is generally not substantial. However, for SingleAsync, Dapper significantly outpaces EF Core, being approximately 3.12x faster.
For GetById operations, EF Core is slightly slower while consuming ~1.52x to 1.60x more memory than Dapper. EF Core still faces challenges with SingleAsync, primarily due to how it generates SQL statements. Specifically, EF retrieves records using TOP 2 instead of TOP 1, which introduces unnecessary overhead in this scenario.
Test 3: Insert Entity and Bulk Insert
Insert a single entity or multiple entities (bulk insert) into the database.
[Benchmark]
public async Task Dapper_Insert_One_Async()
{
var f = _factResellerSales.First();
await _dbConnection.ExecuteAsync(SQLTemplate.InsertTemplate, f);
// Irrelevant for the benchmark since it is part of both methods
await CleanUpAsync();
}
[Benchmark]
public async Task EF_Core_Insert_One_Async()
{
await _dbContext.AddAsync(_factResellerSales.First());
await _dbContext.SaveChangesAsync();
// Irrelevant for the benchmark since it is part of both methods
await CleanUpAsync();
}
[Benchmark]
public async Task Dapper_Insert_Range_Async()
{
await _dbConnection.ExecuteAsync(SQLTemplate.InsertTemplate, _factResellerSales);
// Irrelevant for the benchmark since it is part of both methods
await CleanUpAsync();
}
[Benchmark]
public async Task EF_Core_Insert_Range_Async()
{
await _dbContext.AddRangeAsync(_factResellerSales);
await _dbContext.SaveChangesAsync();
// Irrelevant for the benchmark since it is part of both methods
await CleanUpAsync();
}
public async Task CleanUpAsync()
{
await _dbConnection.ExecuteAsync("DELETE FROM FactResellerSales WHERE CarrierTrackingNumber = 'EF_VS_DAPPER'");
}
I am leveraging Bogus to generate realistic data for insertion into the database. I believe covering realistic test scenarios is crucial in this case to ensure the benchmarks reflect real-world performance. For the Bulk insert, I am generating 30 entities and Bulk Inserting them
NOTE: I am using the same generated entities for test based on specific seed
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| Dapper_Insert_One_Async | 18.27 ms | 0.320 ms | 0.300 ms | 18.23 KB |
| EF_Core_Insert_One_Async | 17.91 ms | 0.234 ms | 0.195 ms | 39.09 KB |
| Dapper_Insert_Range_Async | 22.96 ms | 0.437 ms | 0.569 ms | 427.73 KB |
| EF_Core_Insert_Range_Async | 22.58 ms | 0.201 ms | 0.178 ms | 753.61 KB |
Key Takeaways
Dapper and EF Core exhibit similar performance when it comes to inserting records into the database, but the efficiency varies depending on the scale of the operation.
For single inserts, EF Core is 1.02x faster than Dapper (17.91 ms vs. 18.27 ms). However, EF Core consumes ~2.14x more memory (39.09 KB vs. 18.23 KB), which could be a consideration for memory-constrained applications.
For bulk inserts, EF Core is again 1.02x faster than Dapper (22.58 ms vs. 22.96 ms) when handling smaller datasets, such as 30 records. Yet, the memory usage difference becomes more pronounced, with EF Core consuming ~1.76x more memory (753.61 KB vs. 427.73 KB).
However, as the number of records increases, the advantage shifts dramatically. For larger datasets, such as 10,000 records, Dapper outperforms EF Core by a factor of 2.00x, highlighting Dapper’s superior scalability for high-volume operations.
Test 4: Update Single Entity
Update single entity based on Key or Id
[Benchmark]
public async Task Dapper_Update_Single_Async()
{
var qry = SQLTemplate.UpdateDimProductTemplate;
await _dbConnection.ExecuteAsync(qry, new
{
ProductKey = _productKey,
FrenchProductName = "DAPPER"
});
}
[Benchmark]
public async Task EFCore_Update_Single_Async()
{
var entity = await _dbContext
.DimProducts
.FirstAsync(t => t.ProductKey == _productKey);
entity.FrenchProductName = "EF_VS_DAPPER_EF";
await _dbContext.SaveChangesAsync();
}
For this test, I am updating a single record with a specific ProductKey (ID). From the start, EF Core is at a disadvantage because the record needs to be fetched before it can be updated. Let’s take a look at the results.
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| Dapper_Update_Single_Async | 169.2 us | 3.24 us | 4.10 us | 3.68 KB |
| EFCore_Update_Single_Async | 209.1 us | 4.07 us | 3.81 us | 61.33 KB |
Key Takeaways
Dapper consistently outperforms EF Core in execution speed, but the difference is generally not substantial for single record updates. However, in this test, Dapper significantly outpaces EF Core, being approximately 1.24x faster.
For memory usage, EF Core shows a clear disadvantage, consuming ~16.67x more memory than Dapper. This is primarily due to EF Core’s requirement to fetch the record before updating it and its use of change tracking, which adds overhead.
Conclusion
I personally love Dapper and am firmly on the pro-Dapper team mostly because I enjoy having complete control over my queries and how they are produced. Even if it takes a bit more time to double-check for typos in property names, ensure queries are optimized, or address other minor details, I value this level of control. However, I must remain objective and acknowledge that EF Core has shown significant improvement with .NET 9.
The choice between Dapper and EF Core is now much more of a toss-up in my opinion, and could ultimately be guided by the specific needs of your project. For example:
- Choose Dapper for your most performance-critical scenarios, lightweight operations, and high scalability with large datasets.
- Choose EF Core for more feature-rich applications that require advanced ORM capabilities, LINQ query support, and database versioniong.
Both tools are exceptionally powerful, and understanding their trade-offs allows you to make informed decisions that align with your project’s requirements. The Trailhead team specializes in optimizing data access layers in .NET applications. Ready to enhance your system’s performance? Contact us today!


