.NET & SQL Server

Entity Framework Core: The Performance Traps That Bite in Production

The defaults that make EF Core pleasant with 50 rows in dev are the same ones that melt under 5 million in production. Here are the traps, and the SQL underneath them.

Entity Framework Core is a genuinely great ORM — productive, expressive, and safe by default in most of the ways that matter. But its convenience hides a gap that only opens in production: the code that feels clean and reads well can quietly emit SQL that a DBA would never write by hand. None of the EF Core performance traps below are bugs. They're the distance between "it works on my machine against fifty seed rows" and "it falls over against five million." We tune SQL Server for a living, and these are the patterns we find over and over behind a slow .NET app.

The through‑line for all of them: EF is generating SQL you can't see, and the defaults optimize for developer convenience, not for the query plan. Once you know what to look for, each has a one‑ or two‑line fix.

Trap 1 — The N+1 query

The most common, and the most punishing at scale. You load a list, then touch a navigation property in a loop. EF happily runs a fresh query for each element — one query becomes N+1. With fifty rows nobody notices; with fifty thousand, you've issued fifty thousand round trips to the database.

OrdersService.cs
// TRAP: 1 query for the orders, then one MORE per order to load its customer
var orders = await db.Orders.ToListAsync();          // 1 query
foreach (var o in orders)
    Log(o.Customer.Name);                            // + N queries, one per order

// FIX: tell EF what you need up front, so it's one JOIN and one round trip
var orders = await db.Orders
    .Include(o => o.Customer)
    .ToListAsync();

The fix is to load related data up front with Include, or — better for read paths — to project exactly what you need (Trap 2). The real defense is seeing the SQL: turn on query logging in development (optionsBuilder.LogTo(Console.WriteLine)) and a wall of near‑identical SELECTs makes an N+1 obvious instantly.

Trap 2 — Tracking and SELECT * on read‑only queries

By default every entity EF returns is change‑tracked — it keeps a snapshot so it can detect edits on SaveChanges. That's exactly what you want when you're editing, and pure overhead (memory and CPU) when you're just reading data to render a page. Worse, materializing full entities pulls every column when your view needs two.

UsersQuery.cs
// TRAP: tracks every entity (memory + fixup cost) and SELECTs every column
var users = await db.Users.Where(u => u.IsActive).ToListAsync();

// FIX: no change-tracking for reads, and project only the columns you render
var rows = await db.Users
    .AsNoTracking()
    .Where(u => u.IsActive)
    .Select(u => new UserRow { Id = u.Id, Name = u.Name })  // SELECT Id, Name
    .ToListAsync();

AsNoTracking() drops the bookkeeping for read‑only queries, and projecting to a small DTO with Select turns SELECT * into SELECT Id, Name — less data over the wire, and often a covering index can satisfy the whole query. For a read‑heavy API, these two habits alone are frequently the biggest single win.

Trap 3 — Cartesian explosion from multiple Includes

Include one collection and EF joins it — fine. Include two collections on the same root, and the single SQL query multiplies them together: every post times every contributor, per blog. The row count explodes, the database ships a huge duplicated result set, and EF spends CPU de‑duplicating it in memory.

BlogQuery.cs
// TRAP: two collection Includes multiply rows (a cartesian explosion)
var blogs = await db.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)   // rows = Posts x Contributors, per blog
    .ToListAsync();

// FIX: split into separate queries that EF stitches back together
var blogs = await db.Blogs
    .Include(b => b.Posts).Include(b => b.Contributors)
    .AsSplitQuery()
    .ToListAsync();

AsSplitQuery() tells EF to run one query per collection and reassemble the graph — trading a handful of round trips for an enormous reduction in rows transferred. (The trade‑off: split queries aren't a single consistent snapshot, so wrap them in a transaction if that matters.)

Trap 4 — Row‑by‑row saves instead of set‑based SQL

Databases are built for set‑based work; loops are the opposite. The classic offender: load a pile of entities, change one field on each, and call SaveChanges — which dutifully sends one UPDATE per row. Fifty thousand rows becomes fifty thousand statements plus the cost of materializing and tracking them all.

Cleanup.cs
// TRAP: load 50k rows, flip a flag, SaveChanges = 50,000 UPDATE statements
var stale = await db.Sessions.Where(s => s.LastSeen < cutoff).ToListAsync();
foreach (var s in stale) s.IsExpired = true;
await db.SaveChangesAsync();

// FIX: one set-based UPDATE, zero entities loaded (EF Core 7+)
await db.Sessions
    .Where(s => s.LastSeen < cutoff)
    .ExecuteUpdateAsync(u => u.SetProperty(s => s.IsExpired, true));

ExecuteUpdate and ExecuteDelete (EF Core 7+) push the operation down to a single set‑based statement with no entities loaded at all. For the same‑shaped work, that's the difference between minutes and milliseconds.

The other quiet ones

  • Unbounded queries. .ToListAsync() with no Where or paging pulls the whole table. Always constrain and page (Skip/Take) anything that can grow.
  • Client‑side evaluation. If a query can't be translated to SQL, older EF versions silently ran the filter in memory after fetching everything. Keep filtering and sorting expressible in SQL so it runs in the database.
  • Missing async. Blocking calls (.ToList(), .First()) tie up a thread per request under load. Use the ...Async variants end to end.
  • No indexes for EF's actual queries. EF writes the SQL, but the database still needs the right indexes for it. Capture the generated queries and index for them — which is exactly where the built‑in SQL Server tools earn their keep.

The meta‑lesson: know the SQL your ORM writes

None of this is an argument against EF Core. It's a great tool, and for the majority of a codebase the productivity is worth far more than hand‑written SQL. The point is that an ORM doesn't remove the need to understand the database underneath it — it just hides the SQL until production reminds you it's there. Log the generated queries, read the execution plans, and know when a genuinely hot or complex path is better served by raw SQL or a stored procedure. The teams that get burned are the ones who treated EF as a reason to stop thinking about the database; the ones who thrive use it and keep an eye on what it emits.

This is squarely what we do — the .NET application and the SQL Server underneath it, tuned together. If a .NET app is slow and the database looks busy for no obvious reason, an ORM trap like these is very often the cause. It's the same discipline behind our SQL Server performance tuning work and the database‑saturation investigation we wrote up — find the real query doing the damage, then fix it at the root.

Let's Talk About Your Project

A quick 30‑minute call is all it takes to find out if we're a good fit for each other. Book a time and we'll take it from there.

Book a Call