EF Core habits that quietly wreck performance
EF Core is fast. The code people write on top of it usually isn't. The frustrating part is that none of the slow patterns look slow. They compile, they return the right rows, they pass the tests, and they ship. Then the table grows from two thousand rows to two million and the same endpoint that felt instant starts timing out, and nobody changed a line of code.
Almost every EF Core performance problem I've debugged comes down to a handful of habits. Here are nine, roughly in order of how often they bite, with the worst offenders shown in code. None of these are exotic. That's exactly why they're dangerous.
1. No AsNoTracking on read-only queries
By default, EF Core tracks every entity it materializes so it can detect changes later. For a query whose results you only read and serialize, that bookkeeping is pure overhead: extra allocations, a populated change tracker, and slower materialization.
// Tracked for nothing; you never call SaveChanges on these
var orders = await db.Orders.Where(o => o.CustomerId == id).ToListAsync();
// Tell EF you won't mutate them
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == id)
.ToListAsync();
On a list-heavy read endpoint, AsNoTracking routinely shaves 20-40% off materialization time
and a chunk of allocations. Make it the default for queries that back GET requests.
2. Fetching whole entities when you need three columns
If a screen shows an order's id, total, and status, there is no reason to pull the whole entity
graph (every column, every nullable blob) across the wire. Project to exactly what you need with
Select. EF turns it into a narrower SELECT, and you skip change tracking entirely.
// Pulls every column of Order
var rows = await db.Orders.Where(o => o.IsOpen).ToListAsync();
// Pulls three columns, returns a lightweight shape
var rows = await db.Orders
.Where(o => o.IsOpen)
.Select(o => new OrderListItem(o.Id, o.Total, o.Status))
.ToListAsync();
Projection is the single highest-leverage habit on this list. A narrow Select is faster to
transfer, faster to materialize, and cannot trigger lazy loading later because there's no proxy
to lazy-load from.
3. Forcing client-side evaluation
EF translates the parts of your LINQ it understands into SQL. The moment you call a method it can't translate, evaluation falls back to the client, meaning the unfiltered rows come back first and the filter runs in memory.
// SlugFor() can't be translated, so EF may pull every product, then filter in C#
var p = await db.Products
.Where(x => SlugFor(x.Name) == slug)
.FirstOrDefaultAsync();
Modern EF Core throws on most of these instead of silently degrading, which is a mercy. The fix
is to push the comparison into something translatable (store the slug as a column, or use
EF.Functions) so the database does the filtering it's good at.
4. N+1 from lazy loading
Lazy loading is convenient and quietly catastrophic. Touch a navigation property inside a loop and EF fires one query per iteration.
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
// Each access is a separate round-trip to the database
Console.WriteLine(order.Customer.Name);
}
One hundred orders becomes 101 queries. Load what you need up front with Include, or better,
project the customer name into the result so there's nothing to lazy-load.
var rows = await db.Orders
.Select(o => new { o.Id, Customer = o.Customer.Name })
.ToListAsync();
5. Cartesian explosion without AsSplitQuery
When you Include multiple collection navigations in one query, EF joins them all into a single
result set. Two child collections of 50 rows each don't add up to 100 rows. They multiply into
2,500, most of them duplicated columns from the parent.
var order = await db.Orders
.Include(o => o.LineItems)
.Include(o => o.Shipments)
.AsSplitQuery() // each collection loads in its own query
.FirstOrDefaultAsync(o => o.Id == id);
AsSplitQuery trades one bloated query for a few lean ones. For multiple collection includes
that's almost always the better trade.
6. SaveChanges inside a loop
Each SaveChanges is a round-trip and, by default, a transaction. Calling it per iteration turns
a bulk operation into a thousand sequential round-trips.
// 1,000 round-trips
foreach (var item in items)
{
db.Inventory.Add(item);
await db.SaveChangesAsync();
}
// 1 round-trip (batched)
db.Inventory.AddRange(items);
await db.SaveChangesAsync();
Add everything, save once. EF batches the inserts. For genuinely large volumes, reach past EF
to a bulk-copy path, but AddRange plus one save fixes the common case.
7. Counting (or aggregating) in memory
Pulling rows just to count them drags the entire result set across the wire so you can call
.Count on a list. The database can count without sending you a single row.
// Transfers every matching row, then counts in C#
var total = (await db.Orders.Where(o => o.IsOpen).ToListAsync()).Count;
// One scalar comes back
var total = await db.Orders.CountAsync(o => o.IsOpen);
The same logic applies to Any, Sum, and Max. Let SQL aggregate; don't materialize a list
to ask it one question.
8. ToList too early
ToListAsync is the line where LINQ-to-Entities stops and LINQ-to-Objects begins. Everything
after it runs in memory on rows already fetched. Materialize too soon and your filter, sort, and
paging all happen client-side on the full table.
// Fetches the whole table, then pages in memory
var page = (await db.Orders.ToListAsync())
.OrderByDescending(o => o.CreatedAt)
.Skip(40).Take(20);
// Pushes ordering and paging into SQL, so one short page comes back
var page = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Skip(40).Take(20)
.ToListAsync();
Keep the query a query for as long as possible. Add ToListAsync only when you've expressed
everything the database should do.
9. Missing indexes
The cleanest LINQ in the world is slow against an unindexed column. If you filter or join on something regularly, it usually wants an index. Otherwise every query is a sequential scan that gets linearly worse as the table grows.
modelBuilder.Entity<Order>()
.HasIndex(o => o.CustomerId);
You won't feel this in dev with a few hundred rows. You'll feel it at 3 a.m. when the table has ten million.
How to actually internalize this
The thread running through all nine is the same: the code looks fine, so the only way to catch it is to see what the database actually did. That's the part missing from normal practice. The SQL is invisible until production makes it visible.
It doesn't have to be. The point of Katabench's database track is to put that feedback loop on your desk: your LINQ runs against a real PostgreSQL database, and you see every query it generates. An N+1 stops being an abstract warning and becomes forty queries listed where one belongs. Some puzzles go further and grade the query plan itself, so your solution has to use the index instead of falling back to a sequential scan.
The N+1 starter
… ×41 round-trips
✗ Timeout over budget
The set-based rewrite
1 round-trip
✓ Passed 12 ms
That's the difference between reading this list and owning it. You can memorize "project instead
of fetching whole entities," or you can watch a wide SELECT * collapse to three columns and feel
the materialization time drop. The second one sticks.
The free plan covers the algorithm track; the database track lives on Pro. If you write LINQ for a living, it pays for itself the first time it stops you from shipping an N+1.