Data Access in .NET — EF Core, LINQ, and Dapper (Why, When, and How to Make It Fast)
Deep .NET data access guide: EF Core tracking, projection, AsSplitQuery, ExecuteUpdateAsync, compiled queries, and the EF + Dapper hybrid pattern.
- Author
- Randhir Jassal
- Published
- Reading time
- 24 min read
- Views
- 7 views
Data Access in .NET — EF Core, LINQ, and Dapper (Why, When, and How to Make It Fast)
Data access is where most .NET applications win or lose their performance budget. A query that takes 12 ms in dev returns in 1.2 s under load — not because the database is slow, but because the ORM did 47 round trips when one would have sufficed.
This guide walks through why EF Core, LINQ, and Dapper exist, what problems each solves, and — most importantly — every performance lever you can pull, with real code you can paste into a project today.
TL;DR
- EF Core — use it for 90% of CRUD, anything with relationships, and any code that benefits from compile-time safety. It''s no longer "the slow ORM" if you know its perf knobs.
- LINQ — the query language layer. The same LINQ expression can be 100× faster or slower depending on how it translates to SQL. Read the SQL EF generates. Always.
- Dapper — reach for it on hot read paths (reports, list endpoints, aggregations) where you want hand-tuned SQL and direct materialisation, with none of the change tracker overhead.
- The right answer in 2026 is hybrid: EF Core for writes and most reads, Dapper for the 10% of queries where every millisecond counts. Both run on the same
DbConnection; no architectural cost.
1. The problem each tool solves
1.1 What EF Core actually does for you
EF Core is not "object ↔ row mapping." It''s six things in a trench coat:
- Schema modelling — your
DbContext+DbSet<T>+ Fluent API is a typed description of your database. - LINQ → SQL translator — turns C# expressions into provider-specific SQL.
- Change tracker — watches entities you
Attached/Added/loaded and figures out the rightINSERT/UPDATE/DELETEonSaveChanges(). - Identity map — guarantees that within a context, one row maps to one object reference.
- Migrations engine — schema versioning + rollback.
- Connection / transaction manager — pooled connections, ambient transactions, retries.
When people say "EF is slow," they usually mean items 3 and 4 — the bookkeeping. Items 1, 2, 5, 6 are pure productivity wins.
1.2 What Dapper solves
Dapper is the inverse: it''s a 70-line micro-ORM that does one thing — execute SQL, hydrate rows to objects, return. No tracking, no migrations, no LINQ. You write SQL; Dapper hands you typed results.
using var conn = new SqlConnection(connString);
var top = await conn.QueryAsync<TopSeller>(@"
SELECT TOP 10 p.Id, p.Name, COUNT(o.Id) AS Sales
FROM Products p
JOIN OrderLines o ON o.ProductId = p.Id
WHERE o.CreatedAt >= @from
GROUP BY p.Id, p.Name
ORDER BY Sales DESC", new { from = DateTime.UtcNow.AddDays(-30) });
That''s the whole API surface for 80% of use cases. No DbContext, no [Table] attributes, no entity classes you can''t change shape on.
1.3 Where LINQ fits
LINQ is the language in front of EF Core. It''s also the part that misleads people the most. The C# you write is not the SQL that runs. EF Core translates LINQ to SQL on the fly, and the quality of that SQL is what determines whether your endpoint takes 50 ms or 5 s.
// This LINQ ↓ ...
var orders = await db.Orders
.Where(o => o.CustomerId == customerId)
.Include(o => o.Lines)
.ThenInclude(l => l.Product)
.ToListAsync();
// ...becomes this SQL ↓
// SELECT o.Id, o.CustomerId, o.Total, l.Id, l.OrderId, l.Qty, p.Id, p.Name, p.Price
// FROM Orders o
// LEFT JOIN OrderLines l ON l.OrderId = o.Id
// LEFT JOIN Products p ON p.Id = l.ProductId
// WHERE o.CustomerId = @p_0
// ORDER BY o.Id, l.Id
If you don''t know what SQL you''re generating, you don''t know your performance characteristics. The rest of this article shows you how to find out.
2. EF Core deep dive — the bits that actually matter for perf
2.1 The change tracker is opt-in (in 2026 you should usually opt out for reads)
Every entity EF Core loads with default settings becomes a tracked entity. That means a snapshot of every property is kept in memory, ready to diff against on SaveChanges(). That''s pure overhead if you''re only reading.
// Reads that won't be modified — disable tracking globally for the query.
var users = await db.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();
// Or globally per-context (recommended for read-mostly APIs):
services.AddDbContext<AppDbContext>(o => o
.UseSqlServer(conn)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
// Then opt IN when you need tracking for writes:
var user = await db.Users.AsTracking().FirstAsync(u => u.Id == id);
user.Email = newEmail;
await db.SaveChangesAsync();
In a benchmark of a 1,000-row list endpoint, AsNoTracking() is 30–50% faster and uses about 60% less memory. There is no good reason to track entities you only read.
2.2 Project to DTOs — don''t return entities
The single biggest EF Core perf win that nobody talks about: don''t SELECT *.
// Loads every column of every entity into memory, even if you only display 4 fields.
var products = await db.Products
.Include(p => p.Category)
.Include(p => p.Reviews)
.ToListAsync();
// Projects to a flat DTO. EF generates SELECT only for the columns you ask for.
public record ProductListDto(Guid Id, string Name, decimal Price, string CategoryName, int ReviewCount);
var products = await db.Products
.Select(p => new ProductListDto(
p.Id,
p.Name,
p.Price,
p.Category.Name,
p.Reviews.Count()))
.ToListAsync();
The generated SQL for the second version:
SELECT p.Id, p.Name, p.Price, c.Name AS CategoryName,
(SELECT COUNT(*) FROM Reviews r WHERE r.ProductId = p.Id) AS ReviewCount
FROM Products p
JOIN Categories c ON c.Id = p.CategoryId
No Include cartesian explosion, no overfetching, no client-side projection. This single change typically cuts payload by 70% and query time by 40%.
2.3 The N+1 problem and how to actually fix it
Classic N+1: load N orders, then access order.Customer for each → N additional queries.
// N + 1. One query for orders, then one per order for the Customer navigation.
var orders = await db.Orders.Where(o => o.Total > 100).ToListAsync();
foreach (var o in orders)
{
Console.WriteLine($"{o.Customer.Name}: {o.Total}"); // lazy load each time
}
Fixes, in order of preference:
// Option 1: Project. Best perf, no entity in memory at all.
var rows = await db.Orders
.Where(o => o.Total > 100)
.Select(o => new { o.Total, CustomerName = o.Customer.Name })
.ToListAsync();
// Option 2: Include if you genuinely need the full entities.
var orders = await db.Orders
.Include(o => o.Customer)
.Where(o => o.Total > 100)
.ToListAsync();
// Option 3: For deep graphs that cause join explosion, use AsSplitQuery.
var orders = await db.Orders
.Include(o => o.Lines).ThenInclude(l => l.Product)
.Include(o => o.Customer)
.AsSplitQuery() // EF runs 3 separate SELECTs instead of one huge JOIN
.ToListAsync();
When to choose AsSplitQuery: when one Orders row pulls in 5 Lines and 8 Tags, the joined result has 40 duplicated rows. Split-query cuts that to 1 + 5 + 8 = 14 rows, but pays 3 round trips. For deep graphs, it''s usually a win.
2.4 Compiled queries — for hot paths
Every LINQ expression goes through the same translator pipeline. For queries you run millions of times, you can pre-compile.
private static readonly Func<AppDbContext, Guid, Task<User?>> GetUserByIdQuery =
EF.CompileAsyncQuery((AppDbContext db, Guid id) =>
db.Users.AsNoTracking().FirstOrDefault(u => u.Id == id));
public Task<User?> GetUserAsync(Guid id) => GetUserByIdQuery(_db, id);
Benchmarks (10,000 invocations, simple primary-key lookup):
| Approach | Time | Allocations |
|---|---|---|
| LINQ expression each call | 2.4 s | 145 MB |
EF.CompileAsyncQuery | 1.1 s | 38 MB |
| Dapper raw SQL | 0.9 s | 12 MB |
For "get user by id" hit a thousand times per second, compiled queries close most of the Dapper gap.
2.5 Bulk operations — the EF Core 7+ game-changers
Before EF 7, deleting 100,000 rows meant loading them, marking them, and SaveChanges() — slow and memory-heavy. Now:
// ExecuteDeleteAsync — single DELETE statement, no entities loaded.
var deleted = await db.LogEntries
.Where(l => l.CreatedAt < DateTime.UtcNow.AddDays(-90))
.ExecuteDeleteAsync();
// ExecuteUpdateAsync — single UPDATE statement.
var rowsUpdated = await db.Products
.Where(p => p.CategoryId == oldCatId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.CategoryId, newCatId)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow));
On a 100k-row delete, this goes from ~28 seconds (entity loading + SaveChanges) to ~120 ms (single statement). Use it whenever you''re deleting / updating in bulk by predicate.
2.6 The IQueryable pitfall — composition is not free
IQueryable composes lazily. That''s normally great, but it bites in service layers:
// Returns IQueryable from a repository. Now the consumer can append .ToList()
// which materialises everything before the .Where() the controller adds.
public IQueryable<Product> GetActiveProducts() => _db.Products.Where(p => p.IsActive);
// Somewhere else:
var p = repo.GetActiveProducts().ToList().Where(p => p.Price > 100); // loads ALL active products to memory first
Rule of thumb: only return materialised collections (List<T>, IReadOnlyList<T>, or IAsyncEnumerable<T>) from your repositories/services. Reserve IQueryable for code that owns the DbContext.
2.7 Filtered Includes, navigation pruning, and Includes-with-Where
Need only the recent reviews for each product, not every review ever?
// Filtered Include
var products = await db.Products
.Include(p => p.Reviews.Where(r => r.CreatedAt > DateTime.UtcNow.AddMonths(-3))
.OrderByDescending(r => r.CreatedAt)
.Take(5))
.ToListAsync();
This pushes the filter into the SQL and only materialises the 5 newest reviews per product. Pre-EF 5, you had to project — now it''s a one-liner.
3. LINQ patterns that translate well (and ones that don''t)
3.1 Server-side vs client-side evaluation
EF Core 3+ refuses to silently evaluate on the client. If LINQ contains an expression EF can''t translate, it throws. Good — old EF used to load entire tables to memory to evaluate one .Where().
// Won't translate. C# regex isn't SQL.
db.Users.Where(u => Regex.IsMatch(u.Email, @"^[a-z]+@.*$")).ToList();
// → InvalidOperationException: could not be translated
// Use SQL functions exposed by EF.
db.Users.Where(u => EF.Functions.Like(u.Email, "%@%")).ToList();
// Or call the SQL Server-specific extension.
db.Users.Where(u => EF.Functions.IsMatch(u.Email, @"^[a-z]+@.*$")).ToList();
When you legitimately need a C# operation, materialise first:
var users = await db.Users.AsNoTracking().ToListAsync();
var filtered = users.Where(u => MyCustomBusinessRule(u)).ToList();
3.2 GroupBy is finally translated (mostly)
For a long time, GroupBy was the worst footgun in LINQ-to-EF — it would silently pull the whole table to memory. EF Core 6+ translates most cases:
var revenueByMonth = await db.Orders
.GroupBy(o => new { o.CreatedAt.Year, o.CreatedAt.Month })
.Select(g => new MonthlyRevenue(
g.Key.Year,
g.Key.Month,
g.Sum(o => o.Total),
g.Count()))
.ToListAsync();
Generated SQL:
SELECT DATEPART(year, o.CreatedAt) AS Year,
DATEPART(month, o.CreatedAt) AS Month,
SUM(o.Total) AS Revenue,
COUNT(*) AS OrderCount
FROM Orders o
GROUP BY DATEPART(year, o.CreatedAt), DATEPART(month, o.CreatedAt)
Things that still don''t translate: grouping by an entity reference (GroupBy(o => o.Customer)), or grouping followed by GroupBy on a sub-collection. Always check the generated SQL.
3.3 Pagination — Skip().Take() is fine, but cursor pagination is faster
// Page-based (OFFSET) — gets slower as offset grows because the DB still walks rows.
var page = await db.Posts
.OrderByDescending(p => p.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Cursor-based — O(log n) regardless of position.
var page = await db.Posts
.Where(p => p.CreatedAt < cursor)
.OrderByDescending(p => p.CreatedAt)
.Take(pageSize)
.ToListAsync();
var nextCursor = page.LastOrDefault()?.CreatedAt;
For a 10-million-row table, page 1,000 of OFFSET pagination takes ~800 ms. The same position with cursor pagination takes ~3 ms. Always cursor-paginate when you don''t need random page access.
3.4 Any() vs Count() > 0
// Counts everything, then compares. SQL: SELECT COUNT(*) FROM ...
if ((await db.Products.CountAsync(p => p.CategoryId == catId)) > 0) { }
// Short-circuits on first match. SQL: SELECT TOP 1 1 FROM ...
if (await db.Products.AnyAsync(p => p.CategoryId == catId)) { }
For large tables, this is a 100× difference. Always prefer Any() for existence checks.
4. Dapper — when SQL is the right answer
4.1 The basic shape
public class ReportRepository
{
private readonly string _conn;
public ReportRepository(IConfiguration cfg) => _conn = cfg.GetConnectionString("Default")!;
public async Task<IReadOnlyList<SalesRow>> GetDailySalesAsync(DateOnly from, DateOnly to)
{
using var c = new SqlConnection(_conn);
var rows = await c.QueryAsync<SalesRow>(@"
SELECT
CAST(o.CreatedAt AS DATE) AS [Date],
COUNT(*) AS Orders,
SUM(o.Total) AS Revenue,
AVG(o.Total) AS AvgOrderValue
FROM Orders o
WHERE o.CreatedAt >= @from AND o.CreatedAt < @to
GROUP BY CAST(o.CreatedAt AS DATE)
ORDER BY [Date]",
new { from, to });
return rows.ToList();
}
}
That''s it. No DbContext, no entity, no migration. Dapper''s job ended at "hydrate rows to objects."
4.2 Multi-mapping — joins with two object types in one round trip
var lookup = new Dictionary<Guid, Order>();
var orders = await c.QueryAsync<Order, OrderLine, Order>(@"
SELECT o.Id, o.CustomerId, o.Total, l.Id, l.OrderId, l.ProductId, l.Qty
FROM Orders o
JOIN OrderLines l ON l.OrderId = o.Id
WHERE o.CustomerId = @customerId",
(order, line) =>
{
if (!lookup.TryGetValue(order.Id, out var o))
{
o = order; o.Lines = new List<OrderLine>();
lookup[order.Id] = o;
}
o.Lines.Add(line);
return o;
},
new { customerId },
splitOn: "Id"); // Dapper splits the row at the second Id column
Verbose? A bit. But it''s a single round trip, zero change-tracking, and you control the SQL.
4.3 Multi-result-set — one connection, multiple queries
The big Dapper win: returning several result sets from a single round trip.
using var multi = await c.QueryMultipleAsync(@"
SELECT * FROM Orders WHERE CustomerId = @id;
SELECT * FROM Addresses WHERE CustomerId = @id;
SELECT TOP 5 * FROM PaymentMethods WHERE CustomerId = @id ORDER BY CreatedAt DESC;",
new { id = customerId });
var orders = (await multi.ReadAsync<Order>()).ToList();
var addresses = (await multi.ReadAsync<Address>()).ToList();
var payments = (await multi.ReadAsync<PaymentMethod>()).ToList();
Three queries, one network round trip. For a "customer 360" endpoint, this is the difference between 30 ms and 90 ms.
4.4 Bulk insert with table-valued parameters
public async Task BulkInsertEventsAsync(IEnumerable<AuditEvent> events)
{
var table = new DataTable();
table.Columns.Add("Id", typeof(Guid));
table.Columns.Add("UserId", typeof(Guid));
table.Columns.Add("Action", typeof(string));
table.Columns.Add("CreatedAt", typeof(DateTime));
foreach (var e in events) table.Rows.Add(e.Id, e.UserId, e.Action, e.CreatedAt);
using var c = new SqlConnection(_conn);
await c.ExecuteAsync(
"INSERT INTO AuditEvents (Id, UserId, Action, CreatedAt) SELECT * FROM @events",
new { events = table.AsTableValuedParameter("dbo.AuditEventTvp") });
}
For 10,000 row inserts, this beats EF''s AddRange + SaveChanges by ~20×. For >100k rows, switch to SqlBulkCopy.
5. Performance — the full toolkit
5.1 Connection pooling and DbContext lifetime
// Scoped lifetime — one DbContext per HTTP request. Default for ASP.NET Core.
services.AddDbContext<AppDbContext>(o => o.UseSqlServer(conn));
// Pool DbContext for low-allocation hot paths.
services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(conn), poolSize: 1024);
AddDbContextPool keeps a pool of DbContext instances (resetting state between uses). Saves allocations on high-RPS APIs. The connection-pooling layer in Microsoft.Data.SqlClient is separate and on by default; don''t disable it.
5.2 AsNoTrackingWithIdentityResolution
For read-only queries with self-referencing relationships (graphs, threaded comments), AsNoTracking() will create duplicate references for the same row. Use:
var threads = await db.Comments
.AsNoTrackingWithIdentityResolution()
.Include(c => c.Replies)
.ToListAsync();
You get the no-tracking perf with the identity-map correctness.
5.3 Read with IAsyncEnumerable<T> for streaming
For large result sets that don''t fit in memory comfortably:
await foreach (var product in db.Products.AsNoTracking().AsAsyncEnumerable())
{
await stream.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(product));
await stream.WriteAsync("\n"u8.ToArray());
}
Each row is materialised, streamed, and freed. Memory stays flat regardless of result size.
5.4 Use the right execution method
| Method | When to use |
|---|---|
FirstAsync | You know exactly one row exists; throws otherwise. |
FirstOrDefaultAsync | Zero or one row possible. |
SingleAsync | Exactly one row; throws if more than one. (Adds a TOP 2 check.) |
AnyAsync | Existence checks. |
CountAsync | Only when you need the actual count. |
Single adds a TOP 2 lookup overhead vs First. Don''t use it as a "safer" default — use it when you genuinely want to assert uniqueness.
5.5 Index hints, the right way
Don''t write SQL Server hints in LINQ. Configure the right index in your migration:
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().HasIndex(o => o.CreatedAt);
b.Entity<Order>().HasIndex(o => new { o.CustomerId, o.Status });
b.Entity<Order>().HasIndex(o => o.Total).HasFilter("[Total] > 1000"); // filtered index
b.Entity<Order>().HasIndex(o => o.CustomerId).IncludeProperties(o => new { o.Total, o.Status });
}
The "include columns in index" pattern (the fourth one) creates a covering index — the query can be satisfied without touching the table. This is the single highest-impact perf change on read-heavy schemas.
5.6 Read your generated SQL — LogTo is your friend
services.AddDbContext<AppDbContext>(o => o
.UseSqlServer(conn)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging() // dev only — shows parameter values
.EnableDetailedErrors());
In dev, watch the console for every query. The first time you see a JOIN ... LEFT JOIN ... LEFT JOIN ... that explodes a 100-row table into 80,000 rows, you''ll never Include blindly again.
5.7 Connection string tuning
Server=tcp:db.example.com,1433;
Database=AppDb;
User Id=app_user;
Password=...;
Encrypt=True;
TrustServerCertificate=False;
Connection Timeout=5;
Max Pool Size=200;
Multiple Active Result Sets=False;
Pooling=True;
Key flags:
Max Pool Size— default 100; bump for high-RPS APIs.Connection Timeout— fail fast (5 s) so a flaky DB doesn''t hold request threads.MultipleActiveResultSets— keep OFF unless you have a specific need; enabling it disables some perf paths.
5.8 Caching — the cheapest perf trick
For lookups that change rarely (categories, countries, settings):
// IMemoryCache for in-process caching.
public async Task<List<Category>> GetCategoriesAsync()
{
return await _cache.GetOrCreateAsync("categories:all", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await _db.Categories.AsNoTracking().OrderBy(c => c.Name).ToListAsync();
}) ?? new List<Category>();
}
For multi-instance APIs, swap IMemoryCache for IDistributedCache backed by Redis. A 10-minute cache on a 50 ms query that runs 1,000 times/second is a 99% DB load reduction.
6. The hybrid pattern — EF Core + Dapper in the same project
You don''t have to choose. They share the underlying DbConnection:
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
// Writes — EF Core. Change tracking, navigation properties, validation, audit.
public async Task<Guid> CreateOrderAsync(CreateOrderCommand cmd)
{
var order = new Order(cmd.CustomerId, cmd.Lines);
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order.Id;
}
// Reads on hot paths — Dapper, same underlying connection.
public async Task<IReadOnlyList<SalesRow>> GetDailySalesAsync(DateOnly from, DateOnly to)
{
var conn = _db.Database.GetDbConnection();
return (await conn.QueryAsync<SalesRow>(@"
SELECT CAST(CreatedAt AS DATE) AS [Date], SUM(Total) AS Revenue, COUNT(*) AS Orders
FROM Orders WHERE CreatedAt >= @from AND CreatedAt < @to
GROUP BY CAST(CreatedAt AS DATE) ORDER BY [Date]",
new { from, to })).ToList();
}
}
Now you get the productivity of EF for writes + complex models, and the speed of Dapper for reports and list endpoints. This is what most production .NET apps in 2026 look like.
7. Real benchmarks — numbers from production
Setup: ASP.NET Core 9 minimal API, SQL Server 2022, .NET 9, BenchmarkDotNet, 1,000-row product list with category and review count.
| Approach | Mean | Allocated |
|---|---|---|
EF Core, Include + tracked | 38.2 ms | 4.1 MB |
EF Core, Include + AsNoTracking | 24.7 ms | 1.8 MB |
EF Core, projection to DTO + AsNoTracking | 12.1 ms | 0.6 MB |
EF Core, projection + EF.CompileAsyncQuery | 9.8 ms | 0.4 MB |
| Dapper hand-tuned SQL | 8.4 ms | 0.3 MB |
ADO.NET SqlDataReader direct | 7.9 ms | 0.2 MB |
Takeaways:
- The biggest win comes from projection — 38 ms → 12 ms with one rewrite.
AsNoTracking()+ projection gets you to within 30% of Dapper.- Going to Dapper saves another 4 ms — usually worth it for hot endpoints only.
- Raw
SqlDataReadersaves another 0.5 ms — almost never worth the maintenance burden.
8. The decision matrix
| Scenario | Use |
|---|---|
| CRUD with relationships | EF Core |
| Simple list endpoints | EF Core with projection + AsNoTracking |
| Reports, dashboards, aggregations | Dapper |
| Bulk delete/update by predicate | EF Core ExecuteDeleteAsync / ExecuteUpdateAsync |
| Bulk insert (>10k rows) | Dapper with TVPs, or SqlBulkCopy |
| Complex domain validation on save | EF Core (SaveChanges + interceptors) |
| Stored procedures | Dapper, or EF Core FromSqlRaw |
| Multi-result-set "customer 360" page | Dapper QueryMultipleAsync |
| Polymorphic queries (TPH/TPT) | EF Core |
| Database-first / legacy schema | Dapper, or EF Core scaffolding |
9. The checklist (paste this into your PR template)
Before merging any data-access PR:
- Reads use
AsNoTracking()(or the context defaults to it). - Queries project to DTOs, not entities.
- Generated SQL has been inspected (LogTo or Profiler).
- List endpoints use cursor pagination, not OFFSET.
- Existence checks use
AnyAsync, notCountAsync > 0. - Deep graphs use
AsSplitQuery()or DTO projection. - Bulk operations use
ExecuteDeleteAsync/ExecuteUpdateAsync. - Hot-path queries are
EF.CompileAsyncQuery''d or moved to Dapper. - Indexes cover the WHERE + ORDER BY columns of every list endpoint.
- Lookup tables (<10k rows, low churn) are cached.
10. Closing thoughts
EF Core isn''t slow. Dapper isn''t always the answer. The fastest .NET data layer in 2026 is the one where you read the generated SQL, profile the actual bottlenecks, and pick the right tool per query.
Most performance complaints I see in code reviews come down to three things:
- Tracking when you don''t need to.
- Selecting everything when you need three columns.
- N+1 queries from lazy-loaded navigations.
Fix those, and you''ll close 80% of the gap between EF Core and "I should rewrite this in Dapper." Save Dapper for the queries that genuinely deserve it — your reports, your dashboards, your hottest list endpoints.
Further reading
- EF Core 9 performance docs — official guide; mandatory reading.
- Dapper GitHub — the README is the docs.
- BenchmarkDotNet — measure before you optimise.
Have a tricky query you''re trying to make faster? Email randhir.jassal@gmail.com with the SQL and the plan, and I''ll tell you what I''d do.
Get the next issue
A short, curated email with the newest posts and questions.