I’ll never forget the first time I got called at midnight for a performance issue that was my fault. A dashboard in our SaaS app was timing out, and customers were angry. After hours of digging, the culprit was a monster LINQ query generated by Entity Framework Core. It was trying to be clever, but the generated SQL was a performance disaster.
We rewrote that one query using raw SQL with Dapper, and boom: the endpoint went from taking 15 seconds to under 200 milliseconds.
That experience taught me a crucial lesson. The endless debate about EF Core vs. Dapper is asking the wrong question. It’s not about which one is “better.” It’s about knowing which tool to grab for the job at hand. This isn’t about benchmarks; it’s about shipping features that are fast and easy to maintain.
Let’s break down how I decide in the real world.
EF Core: Your Go-To for Business Logic
I start every new project with EF Core. The productivity boost is just too massive to ignore. It shines when your main job is translating complex business rules into code.
Modeling Your Business, Not Just Tables
When your app has real business rules, you want your code to reflect that. EF Core’s mapping for C# objects (POCOs) is fantastic. Features like navigation properties, value objects, and owned types let you build a rich domain model that makes sense.
Think about an Order
class. With EF Core, it’s clean and reflects the business.
public class Order
{
public Guid Id { get; set; }
public Money Total { get; set; } // A rich value object, not just a decimal
public Address ShippingAddress { get; set; } // An owned type
public List<OrderItem> Items { get; set; } // Navigation property
public Customer Customer { get; set; }
}
Trying to assemble this object manually with Dapper would mean writing a bunch of boilerplate mapping code. It gets messy, fast. This one burned me once on a project where we tried to be “pure Dapper”—the mapping logic became a huge source of bugs.
Change Tracking is the Real Magic
This is the feature you’ll miss most if you don’t use EF Core. The DbContext
keeps track of all the changes you make to your objects. You can pull an object, change a property, add a child to a collection, and then call SaveChangesAsync()
once. EF Core figures out the right INSERT, UPDATE, and DELETE statements and wraps it all in a transaction.
// EF Core knows the relationships and handles the updates.
var order = await dbContext.Orders
.Include(o => o.Items)
.FirstAsync(o => o.Id == orderId);
order.Status = OrderStatus.Processing;
order.Items.Add(new OrderItem { ProductId = productId, Quantity = 2 });
await dbContext.SaveChangesAsync(); // All changes are saved in one transaction.
Safe and Sane Schema Migrations
Database schema getting out of sync with your code is a classic deployment nightmare. EF Core’s code-first migrations pretty much solve this. Your schema is version-controlled in C# right alongside your application code. Deployments become predictable and far less scary.
Dapper: The Scalpel for Raw Speed
Dapper is a micro-ORM. It’s ridiculously fast because it has almost no overhead. It’s designed to do one thing perfectly: map the results of a raw SQL query to your C# objects.
When Your API Needs to Be Blazing Fast
For high-traffic read operations, Dapper is king. That dashboard story I mentioned? That’s the perfect use case. When you have a “hot path” in your application—an API endpoint that gets hammered with requests—you need raw performance.
You write the SQL, Dapper does the mapping. That’s it.
// No magic, just speed. Perfect for read-heavy DTOs.
var users = await connection.QueryAsync<UserDto>(
"SELECT Id, Name, Email FROM Users WHERE IsActive = 1"
);
For Those Gnarly SQL Reports
Sometimes, you just need to write a crazy, 100-line SQL query with CTEs, window functions, or special database hints to get the data for a report. EF Core’s LINQ-to-SQL translator is good, but it can’t always produce the most optimized SQL for these complex scenarios.
With Dapper, you write the exact SQL you need. You have full control over the execution plan, which is critical for complex analytics and reporting queries. I don’t recommend this for everything, but when you need that level of control, Dapper is the answer.
A Peek at Our Production Telemetry
Benchmarks are fine, but I trust the data from systems I’ve actually shipped. These numbers are from a multi-tenant SaaS platform I helped build, handling millions of records on a well-indexed SQL Server.
Quick disclaimer: Your results will be different! This is just to show the relative difference in a real-world workload.
Operation Type | EF Core (avg ms) | Dapper (avg ms) | Where We Used It |
---|---|---|---|
Simple CRUD Operations | 12-15 ms | 8-10 ms | Standard business logic |
Complex Read (5+ Joins) | 45-60 ms | 25-30 ms | Reporting dashboards |
Bulk Inserts (1k rows) | 200-300 ms | 80-120 ms | Data import jobs |
Get Single Entity by ID | 8-10 ms | 5-7 ms | API GET /items/{id} endpoints |
The lesson is clear: for simple stuff, the difference is small enough that EF Core’s productivity wins. The huge gains for Dapper are in complex reads and bulk data operations.
The Pro Move: A Hybrid CQRS Approach
You don’t have to pick one. The best systems I’ve built use both. This pattern naturally aligns with Command Query Responsibility Segregation (CQRS).
- Use EF Core for Commands (Writes): When you’re creating, updating, or deleting data, use EF Core. You get the rich domain modeling, change tracking, and transactional safety your business logic needs.
- Use Dapper for Queries (Reads): When you’re just fetching data to display, use Dapper with optimized, raw SQL queries that return simple DTOs. This gives you maximum performance where users feel it most.
Here’s a quick look at how that feels in code:
// The Command side uses EF Core for its robust features.
public class CreateOrderCommandHandler
{
private readonly MyDbContext _dbContext;
public async Task<Guid> Handle(CreateOrderCommand command)
{
var order = new Order(command.CustomerId, command.Items);
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(); // Transactional and safe.
return order.Id;
}
}
// The Query side uses Dapper for raw speed.
public class GetOrderSummaryQueryHandler
{
private readonly IDbConnection _connection;
public async Task<OrderSummaryDto> Handle(GetOrderSummaryQuery query)
{
const string sql = @"
SELECT o.Id, o.TotalValue, c.Name as CustomerName
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
WHERE o.Id = @orderId";
return await _connection.QuerySingleOrDefaultAsync<OrderSummaryDto>(
sql, new { query.orderId }
);
}
}
This architecture gives you the best of both worlds: productivity and safety on the write side, and pure speed on the read side.
Here’s When I’d Use It, Here’s When I’d Avoid It
Stop thinking of it as a competition. Think of it as a toolkit.
Here’s my playbook:
- Default to EF Core. For 80% of your application (the business logic, the CRUD screens), the productivity and safety it provides is unbeatable.
- Find the real bottlenecks. Don’t guess. Use a profiler like MiniProfiler or Application Insights to find the queries that are actually slow in production. Premature optimization is a trap.
- Use Dapper as a scalpel. When you find a slow query, rewrite just that one query with Dapper. You don’t need to rip out EF Core everywhere.
- Prioritize readable and secure code. A few saved milliseconds aren’t worth it if your code is unmaintainable or vulnerable to SQL injection. EF Core’s parameterization is a great safety net.
The bottom line is simple: EF Core is your workhorse for building robust systems quickly. Dapper is your specialized tool for the critical paths where performance is non-negotiable. Use them together, and you can build something great.