EF Core vs Dapper: Stop Arguing and Just Use Both
If you hang around .NET forums long enough, you will eventually stumble into a holy war: Entity Framework Core vs. Dapper.
The Dapper crowd aggressively argues that EF Core is a bloated, slow abstraction that generates horrific SQL queries and hides what the database is actually doing. The EF Core crowd argues that Dapper requires you to write fragile magic strings, manage your own database migrations like a caveman, and write hundreds of lines of boilerplate just to update a simple record.
I’ve built massive enterprise systems using both exclusively, and the absolute truth is this: picking one over the other is a false dichotomy. The absolute best way to build a high-performance, maintainable .NET application is to use them both in the exact same project.
Here is an exhaustive architectural guide on how I mix EF Core and Dapper to get the rapid development speed of an ORM where it counts, and the raw bare-metal performance of SQL where you need it.
Table of Contents
- 1. The Command Query Responsibility Segregation (CQRS) Approach
- 2. Why EF Core is King of Writes (Commands)
- 3. Why Dapper is King of Reads (Queries)
- 4. The Secret Weapon: Sharing the Connection
- 5. Sharing Transactions Between Both Tools
- 6. When Should You NOT Use Dapper?
- Final Verdict
1. The Command Query Responsibility Segregation (CQRS) Approach
The core principle behind using both tools is separating your Writes from your Reads. This architectural pattern is known as CQRS (Command Query Responsibility Segregation).
You don't necessarily need a massive MediatR setup with highly separated physical databases to achieve this. You simply need to strictly enforce a rule in your codebase:
- If a method modifies data (Insert, Update, Delete), it uses Entity Framework Core.
- If a method strictly reads data to display to a user, it uses Dapper.
By splitting these responsibilities, you allow the tool that is objectively better at the task to take over.
2. Why EF Core is King of Writes (Commands)
Entity Framework Core's most powerful feature is often the one junior developers complain about the most: The Change Tracker.
When you need to load a complex object graph, modify a few deeply nested fields, and save it back to the database, EF Core handles the transaction and generates the exact UPDATE statements flawlessly. It knows exactly which properties were modified and ignores the ones that weren't.
// Modifying complex graph data is trivial in EF Core
public async Task ShipOrderAsync(Guid orderId)
{
var order = await _db.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null) return;
// Mutate the objects in memory
order.Status = OrderStatus.Shipped;
order.LineItems.First().ShippedDate = DateTime.UtcNow;
// EF Core figures out the exact SQL needed.
await _db.SaveChangesAsync();
}
Trying to replicate this workflow in Dapper is a nightmare. It requires writing complex multi-mapping queries to hydrate the object graph, manually tracking which C# fields changed in memory, and writing custom UPDATE statements for both the Orders table and the LineItems table. It's incredibly tedious and highly error-prone.
Furthermore, EF Core gives you the power of Database Migrations. Managing schema changes through source-controlled C# code is vastly superior to manually executing SQL scripts against production. (If you want to know how to deploy those migrations safely, read my guide on EF Core Migrations in Production).
3. Why Dapper is King of Reads (Queries)
When you are displaying a dashboard to a user, you rarely need a fully tracked, deeply nested Domain Entity. You just need a flattened Data Transfer Object (DTO), which usually takes the form of a C# record. (For more on this, see my guide: C# Records vs Classes).
This is where EF Core struggles. Even if you use .AsNoTracking(), EF Core has significant overhead translating your C# LINQ tree into SQL, executing it, tracking relationships, and mapping the results.
Dapper, being a micro-ORM, executes raw SQL and maps the result set directly to a C# object via reflection (and increasingly, source generators). It has almost zero overhead. It is blisteringly fast, making it the top choice for developers focused on .NET 8 Performance.
// Dapper for a complex, high-performance read query
public async Task<IEnumerable<OrderDashboardDto>> GetDashboardAsync(string status)
{
var sql = @"
SELECT
o.Id,
o.OrderDate,
c.Name AS CustomerName,
SUM(i.Price * i.Quantity) AS TotalAmount
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
INNER JOIN LineItems i ON o.Id = i.OrderId
WHERE o.Status = @Status
GROUP BY o.Id, o.OrderDate, c.Name";
using var connection = new SqlConnection(_connectionString);
// Parameterized queries prevent SQL Injection natively
return await connection.QueryAsync<OrderDashboardDto>(sql, new { Status = status });
}
Writing that specific GROUP BY query in EF Core LINQ is confusing. It often generates inefficient SQL that pulls back way more columns than you actually need, or worse, evaluates the grouping in memory on the server. With Dapper, you have absolute control over the SQL execution plan and can utilize database-specific features like SQL Server Window Functions.
4. The Secret Weapon: Sharing the Connection
The absolute beauty of this architectural approach is that EF Core and Dapper actually play very nicely together.
Dapper is implemented as a series of extension methods on the IDbConnection interface. Because EF Core's DbContext manages a database connection internally, you don't need to manually instantiate a new SqlConnection for Dapper. You can actually pull the active connection out of EF Core and pass it directly to Dapper!
// Inject the EF DbContext via Dependency Injection
public class ReportService(ApplicationDbContext db)
{
public async Task<IEnumerable<UserDto>> GetUsersAsync()
{
// Extract the underlying connection from EF Core
var connection = db.Database.GetDbConnection();
// Execute a Dapper query using the EF Core connection!
return await connection.QueryAsync<UserDto>("SELECT Id, Name FROM Users");
}
}
(Need help with DI? Check out my guide on Advanced Dependency Injection in .NET).
5. Sharing Transactions Between Both Tools
Because you can share the connection, you can also share the Transaction. This allows you to mix EF Core commands and Dapper commands in the exact same unit of work safely.
Imagine a scenario where you need to use EF Core to insert a complex entity, but immediately need to run a highly optimized Dapper bulk-update command that would be too slow in EF Core.
public async Task ProcessMassiveOrderAsync(Order newOrder)
{
// 1. Start a transaction using EF Core
using var transaction = await _db.Database.BeginTransactionAsync();
var connection = _db.Database.GetDbConnection();
var dbTransaction = transaction.GetDbTransaction();
try
{
// 2. Write using EF Core
_db.Orders.Add(newOrder);
await _db.SaveChangesAsync();
// 3. Perform a massive bulk update using Dapper in the SAME transaction
var sql = "UPDATE Inventory SET Stock = Stock - 1 WHERE CategoryId = @CatId";
await connection.ExecuteAsync(sql, new { CatId = 5 }, dbTransaction);
// 4. Commit everything together
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
This hybrid approach gives you unparalleled flexibility.
6. When Should You NOT Use Dapper?
While Dapper is incredible for queries, you should avoid it for simple CRUD (Create, Read, Update, Delete) operations by ID.
If you just need to fetch a single user by their ID, await _db.Users.FindAsync(id); is vastly superior to writing SELECT * FROM Users WHERE Id = @Id in Dapper. EF Core's primary key lookup is highly optimized, type-safe, and avoids the risk of typos in raw SQL strings. Save Dapper for the complex dashboard screens, heavy reporting aggregations, and places where you need absolute control over the SQL execution plan.
Final Verdict
Stop treating data access as a zero-sum game. The best architectures utilize the right tool for the job.
Use Entity Framework Core for:
- Database Migrations.
- Simple CRUD operations by ID.
- Adding, Updating, and Deleting complex relational data.
- Enforcing Domain-driven design constraints.
Use Dapper for:
- Complex reporting queries with
GROUP BYand Window Functions. - High-throughput, read-heavy endpoints where milliseconds matter.
- Edge cases where EF Core's LINQ provider generates terrible SQL.
By combining them, you elevate your .NET architecture from "good enough" to enterprise-grade.
Tired of mapping Dapper results by hand? If you are writing Dapper queries and constantly manually typing out the C# DTO records to match the JSON/SQL results, you are wasting time. Check out my completely free JSON to C# Converter to instantly generate pristine C# classes from your data payloads. No strings attached!