Lesson 20 of 45 advanced

Query Optimization in EF Core

Fewer round trips, fewer columns, and let the database do the filtering

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

The SQL log shows 201 almost identical SELECT statements for one page load. What is the most likely cause?

Open interactive version (quiz + challenge)

Real-world analogy

Imagine sending a courier to a warehouse. The slow way is one trip to get the list of orders, then one separate trip per order to fetch its customer name. The better way is one trip with a written note: "bring me only these three columns, for the first twenty paid orders". The warehouse does the picking, and the courier carries a small box home. The many trips are the N+1 problem, and the note is a query the database runs for you.

What is it?

EF Core query optimization means controlling what SQL your LINQ turns into. A query stays on the database side while you chain Where, OrderBy, Skip, Take and Select on the IQueryable. It moves into memory the moment you call ToList, ToArray or iterate it. The goal is to send few queries, return few columns and rows, and let the database do the filtering, sorting and counting, because it has the indexes.

Real-world relevance

A list page that felt fast with ten test rows takes eight seconds with fifty thousand. The usual causes are a loop that triggers one query per row, a screen that loads whole entities to show two fields, and paging done in memory. Developers find them by reading the logged SQL, not by guessing. Fixing them often turns hundreds of queries into one, with no change to what the user sees.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Which is better for checking whether any matching row exists?

Why add OrderBy before Skip and Take?

Code example

public record OrderRow(int Id, string CustomerName, decimal Total);

// Slow shape (lazy loading on): one query for orders, then one per order for its customer
//   var orders = await db.Orders.ToListAsync(ct);
//   foreach (var o in orders) names.Add(o.Customer.Name);

public async Task<List<OrderRow>> GetPaidPageAsync(ShopContext db, int page, int pageSize, CancellationToken ct)
{
    return await db.Orders
        .AsNoTracking()
        .Where(o => o.Status == "Paid")
        .OrderByDescending(o => o.Id)          // unique column keeps pages stable
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o => new OrderRow(
            o.Id,
            o.Customer.Name,                   // becomes a JOIN, not a query per row
            o.Items.Sum(i => i.Price * i.Quantity)))
        .ToListAsync(ct);
}

public Task<bool> HasPaidOrdersAsync(ShopContext db, int customerId, CancellationToken ct)
{
    // EXISTS in SQL: stops at the first match
    return db.Orders.AnyAsync(o => o.CustomerId == customerId && o.Status == "Paid", ct);
}

Line-by-line walkthrough

  1. 1. OrderRow is a small record with only the three values the page shows.
  2. 2. The commented lines show the slow shape: with lazy loading on, touching o.Customer in the loop runs one query per row.
  3. 3. Where, OrderByDescending, Skip and Take all run before ToListAsync, so the database filters and pages.
  4. 4. OrderByDescending on Id gives a stable order. Without one, rows can repeat or vanish between pages.
  5. 5. The Select reads the customer name and a sum of line totals, and EF Core joins them in one SQL statement.
  6. 6. HasPaidOrdersAsync uses AnyAsync, which becomes EXISTS and stops at the first match.

Spot the bug

public async Task<int> CountPaidAsync(ShopContext db)
{
    var all = await db.Orders.ToListAsync();   // loads every order

    var paid = all.Where(o => o.Status == "Paid").ToList();
    return paid.Count;
}
Need a hint?
Where does the filtering happen after ToListAsync?
Show answer
ToListAsync runs the query immediately, so the entire Orders table is loaded into memory and then filtered in C#. Fix: let the database count with return await db.Orders.CountAsync(o => o.Status == "Paid"), which sends one COUNT query and returns one number.

Explain like I'm 5

Do not walk to the shop ten times for ten items. Write one list, go once, and ask the shop to put only the things you need in your bag.

Fun fact

Skip(100000) still makes the database walk past 100,000 rows before it returns your page. That is why deep pages get slower, and keyset pagination avoids it.

Hands-on challenge

Say it out loud in 30 seconds: "An EF Core list page is slow. How do you find out why and fix it?" Then check: did you mention logging the generated SQL, the N+1 problem, Select projection, and filtering and paging in the database?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook