Katabench
Start free
← All posts

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

Same rows returned. The grader shows you what each approach cost.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.