.NET EF Core Should Be Your Default in 2026 — Not Dapper
TL;DR — The performance gap that once justified defaulting to Dapper has narrowed dramatically. In modern production systems, network latency, I/O, and architectural clarity dominate request cost. EF Core should now be your default — and Dapper your precision tool.
This is not ideology.
This is 2026 reality.
The Outdated Default
For years, the advice was simple:
“Default to Dapper. Use EF Core only when you need ORM features.”
The reasoning was benchmark-driven.
// Dapper
var users = await connection.QueryAsync<User>(
"SELECT * FROM Users WHERE IsActive = 1");
versus
// EF Core
var users = await db.Users
.Where(u => u.IsActive)
.ToListAsync();
Microbenchmarks showed Dapper outperforming EF Core by multiples.
The problem?
Those benchmarks were:
- Single-table queries
- CPU-bound
- Run in isolation
- Detached from real application architecture
Production systems are not microbenchmarks.
What Changed in EF Core
Recent EF Core releases (7, 8, and into 2026 previews) delivered:
- Faster query pipeline
- Improved materialization
- Better expression caching
- Reduced tracking overhead
- Compiled query optimizations
- Split query improvements
The result: real-world parity for many workloads.
The old headline — “Dapper is 3x faster” — no longer holds universally.
And performance parity changes the default decision.
Production Reality: Where Time Is Actually Spent
Consider a typical API request:
- Middleware pipeline
- Authentication
- Logging
- Network round-trip to database
- ORM materialization
- Serialization
- Network response
In most services:
- Database round-trip: milliseconds
- ORM materialization: microseconds
- Serialization: often more expensive than ORM
- Logging & filters: measurable cost
If the database call consumes less than ~40% of total request time, switching ORMs will not materially change your latency.
This is the architectural lens most teams miss.
Developer Velocity Is the Real Multiplier
Let’s compare failure modes.
EF Core — Compile-Time Safety
var orders = await db.Orders
.Where(o => o.CreatedAtDate > DateTime.UtcNow)
.ToListAsync();
If CreatedAtDate does not exist, this fails at compile time.
Dapper — Runtime Risk
await connection.QueryAsync<Order>(
"SELECT * FROM Orders WHERE CreatedAtDate > @date",
new { date = DateTime.UtcNow });
A typo compiles.
It fails in production.
The difference is not philosophical.
It is operational.
Over years, compile-time safety compounds into fewer incidents.
EF Core and Complex Object Graphs
Modern applications are not flat tables.
They include:
- Aggregates
- Navigation properties
- Owned types
- Relationships
- Value objects
EF Core handles this natively:
public async Task<OrderSummaryDto?> GetOrderSummaryAsync(int orderId)
{
return await _db.Orders
.AsNoTracking()
.Where(o => o.Id == orderId)
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
OrderDate = o.CreatedAt,
CustomerName = o.Customer.FirstName + " " + o.Customer.LastName,
Items = o.Items.Select(i => new OrderItemDto
{
ProductName = i.Product.Name,
Quantity = i.Quantity,
LineTotal = i.Quantity * i.UnitPrice
}).ToList(),
Total = o.Items.Sum(i => i.Quantity * i.UnitPrice) + o.TaxAmount
})
.FirstOrDefaultAsync();
}
Single projection.
Single LINQ pipeline.
Strong typing across joins.
The Dapper equivalent requires:
- Manual SQL
- Multi-mapping
-
splitOnconfiguration - Manual aggregation logic
That complexity is not free.
Performance Patterns That Actually Matter
EF Core performance is not about ORM choice.
It is about usage patterns.
1. Disable Tracking for Reads
var products = await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
Tracking removed → overhead removed.
2. Project Only What You Need
var list = await db.Products
.AsNoTracking()
.Select(p => new ProductListItem
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync();
Reduced payload.
Reduced memory pressure.
Reduced serialization cost.
3. Avoid N+1
var orders = await db.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.AsSplitQuery()
.ToListAsync();
AsSplitQuery() prevents join explosion.
This matters more than ORM selection.
4. Drop to SQL When Necessary
var metrics = await db.Database
.SqlQueryRaw<DashboardMetric>(sql, parameters)
.ToListAsync();
EF Core does not prevent optimization.
It allows escape hatches.
Where Dapper Still Wins
Let’s be precise.
Dapper is superior when:
- Bulk inserts exceed 10,000 rows
- You have known, measured hotspots
- You need extremely narrow reporting queries
- You want full SQL control and accept manual mapping
Example hybrid pattern:
public async Task BulkInsertProductsAsync(List<Product> products)
{
if (products.Count > 10000)
{
await _connection.ExecuteAsync(sql, products);
}
else
{
await _db.Products.AddRangeAsync(products);
await _db.SaveChangesAsync();
}
}
This is not ideology.
It is selective optimization.
Production-Grade EF Configuration
Your configuration matters more than your ORM.
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("Default"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(5, TimeSpan.FromSeconds(30), null);
sqlOptions.CommandTimeout(30);
});
if (builder.Environment.IsDevelopment())
{
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging();
}
});
And model tuning:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt });
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted);
modelBuilder.Entity<Order>()
.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.OnDelete(DeleteBehavior.Restrict);
}
Indexes and query filters will outperform ORM swapping.
Every time.
Decision Framework for 2026
START
-
Are you handling proven hotspots with high throughput?
- Profile first. Then consider Dapper.
-
Do you operate with complex relationships?
- EF Core.
-
Does your team value refactor safety?
- EF Core.
-
Are you bulk-processing massive datasets?
- Dapper (for that path only).
DEFAULT → EF Core.
The Honest Recommendation
Default to EF Core.
Then:
- Measure your system.
- Identify bottlenecks.
- Optimize targeted paths.
- Introduce Dapper surgically where justified.
Prematurely defaulting to Dapper introduces:
- Manual SQL debt
- Runtime errors
- Reduced refactor safety
- Increased cognitive load
Developer velocity compounds.
Compile-time guarantees compound.
Architectural clarity compounds.
Final Thought
In 2026, performance parity is real for most workloads.
The debate is no longer about speed.
It is about maintainability, safety, and engineering leverage.
EF Core should be your default.
Dapper should be your scalpel.
— Written by Cristian Sifuentes
Full‑stack engineer · .NET architect · Systems thinker

Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.