Skip to main content
ANVISoftware Solutions
Lesson 17 of 17Advanced18 min

Query Optimisation

By the end of this lesson

Diagnose a slow query from the generated SQL and its plan.

A slow page is a measurement problem before it is a coding problem. The usual failure is to guess: add an index, rewrite a Where clause, sprinkle in AsNoTracking, and hope the timing improves. Sometimes it does, and you still do not know why.

The method below takes longer to start and much less time to finish, because every step produces evidence. At the end you know what you changed, what it did, and what to tell the next person who opens the file.

Six steps, in order. The order is the method:

  1. 1. Find out which query is slow

    Time the endpoint or the page, then log the SQL EF Core sent during it. One slow statement, several medium ones, or four hundred fast ones are three different problems with three different fixes, and they are indistinguishable from the outside.

  2. 2. Get the exact SQL

    ToQueryString() gives you the statement without running it. Logging gives you the statement with its parameter values and its duration. You need the real text, not your idea of what EF Core produced.

  3. 3. Run it against the database yourself

    In a query tool, against data of a realistic size. If it is slow there too, the problem is the query or the schema. If it is fast there, look instead at row counts, object materialisation and the number of round trips.

  4. 4. Read the execution plan

    The plan is the database's own account of how it answered. Look for a scan where you expected a seek, a large gap between estimated and actual rows, and sorts that spill to temporary storage.

  5. 5. Change one thing

    An index, a narrower projection, a predicate the index can use, paging instead of everything. One change at a time, so the next measurement means something.

  6. 6. Measure again, and keep the evidence

    Record the before and after in the pull request, including the numbers. The next person to open this query needs to know what was already tried and rejected, or they will try it again.

Making the SQL visible
C#
// Development configuration only.
options.UseSqlServer(connectionString)
       .LogTo(Console.WriteLine, LogLevel.Information)
       .EnableSensitiveDataLogging();

IQueryable<Order> query = context.Orders
    .Where(o => o.Status == OrderStatus.Open)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product);

// The statement, without executing it.
Console.WriteLine(query.ToQueryString());
  • LogTo sends EF Core's log anywhere you can write a string. At Information level you get each command, its parameters and how long it took, which is three of the numbers you need.
  • EnableSensitiveDataLogging is what makes parameter values appear. It is genuinely useful while diagnosing and has no place in production, where those values would end up in log storage.
  • ToQueryString() prints the SQL for a query you have built but not run. Useful in a test or a scratch endpoint when you want the text without waiting for a slow result.
  • Count the statements as well as reading them. If the log shows the same statement 200 times with different parameters, stop here — that is the N+1 problem, and no amount of plan reading will fix it.
The statement, and what the database said about it
SQL
SET STATISTICS IO ON;
SET STATISTICS TIME ON;

SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate], [o].[Status], [o].[Notes],
       [i].[Id], [i].[Quantity], [i].[UnitPrice],
       [p].[Id], [p].[Name], [p].[Sku]
FROM [Orders] AS [o]
LEFT JOIN [OrderItems] AS [i] ON [o].[Id] = [i].[OrderId]
LEFT JOIN [Products] AS [p] ON [i].[ProductId] = [p].[Id]
WHERE [o].[Status] = 1
ORDER BY [o].[Id], [i].[Id];

-- What the plan and the statistics showed:
--   Clustered index scan on Orders: 1,400,000 rows read
--   Rows returned: 84,000
--   Estimated rows 900, actual rows 84,000
--   Sort operator warning: spilled to temporary storage
--   Elapsed 2,140 ms, of which most was the scan and the sort
  • A scan on Orders with a Status predicate means no index supports that predicate, so the database read the whole table to find roughly one row in seventeen. That is the first candidate.
  • The gap between estimated and actual rows matters as much as the scan. The plan was built expecting 900 rows and handed 84,000, so the join and sort strategies it chose were the wrong ones for the real volume. Stale statistics are a common cause and are cheap to rule out.
  • The sort spilling to temporary storage is the ORDER BY EF Core added so it could assemble the object graph — on far more rows than it should have had.
  • Now notice what the plan cannot tell you: 84,000 rows for a few thousand orders means the parent columns are being repeated per order item. That is a query shape problem, and no index fixes it.
The reshaped query
C#
// One row per order, only the columns the screen shows, one page at a time.
List<OpenOrderRow> page = await context.Orders
    .Where(o => o.Status == OrderStatus.Open)
    .OrderByDescending(o => o.OrderDate)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .Select(o => new OpenOrderRow(
        o.Id,
        o.OrderDate,
        o.Customer.Name,
        o.Items.Count,
        o.Items.Sum(i => i.Quantity * i.UnitPrice)))
    .ToListAsync();
  • Three changes, each with its own reason. The projection cuts the columns and removes the repeated parent rows. The paging caps the work at one screen. A supporting index on (Status, OrderDate) makes the filter a seek and supplies the sort order, so nothing has to be sorted at all.
  • There is no AsNoTracking here, and that is not an omission: a projection to a row type is not an entity, so there was never anything to track.
  • Apply these one at a time and measure after each. Otherwise you will not know which one mattered, and you will maintain the other two forever on the assumption that they did.
  • Skip and Take need a deterministic sort to be meaningful. OrderDate alone can tie, so for a stable page order add a tie-breaker such as Id.

Reading a plan gets much easier once you know the five things worth checking:

Scan versus seek
A scan reads an entire table or index. A seek jumps to the rows it needs. A scan on a large table with a selective predicate usually means a missing index, or one the query cannot use.
Estimated versus actual rows
A large gap means the plan was chosen for the wrong data volume. Update statistics and look again before you start rewriting the query.
The most expensive operator
Plans attribute cost to each step. Fix the step at the top of that list. The rest is usually noise, and time spent on it is time not spent on the problem.
Sorts and spills
A sort that spills to temporary storage is doing its work outside memory. Either the row count is higher than it should be, or an index could have supplied the order for free.
Rows read versus rows returned
Reading 1.4 million rows to return 84,000 is the single number that says where the time went. It also tells you whether to look at the index or at the query shape.

Summary

  • Optimisation is a measurement loop: get the SQL, run it, read the plan, change one thing, measure again
  • Log the statements and count them, because 400 fast queries and one slow query look identical from outside
  • The plan tells you scan against seek, estimated against actual rows, and where the cost sits
  • Rows read against rows returned separates an index problem from a query shape problem
  • Change one thing at a time and record the before and after, so the next person inherits evidence

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Two numbers, no changes

Pick the slowest read path you have. Log its SQL, run the statement in a query tool with statistics on, and write down two numbers: rows read and rows returned.

Do not change anything yet. Then decide, from those two numbers alone, whether you are looking for an index or a different query shape.

Show solution

Many rows read and few returned points at the predicate: the database is filtering after reading, which is what a missing or unusable index looks like.

Rows read and rows returned both high, for a result that should be small, points at shape: joins multiplying rows, or no paging, or a query fetching a whole table so the application can take twenty items from it.

The reason for not changing anything yet is that these two numbers narrow the search before you have spent any effort. Most wasted optimisation work is a correct fix applied to the wrong problem.

Think about it

Fast in the tool, slow in the application

The log shows one statement taking 1.9 seconds. You copy it into a query tool and it runs in 40 milliseconds, repeatedly.

Name two explanations, and say how you would tell them apart.

Show solution

First: the application and the tool are running different plans. Parameter types are a common cause — a string parameter sent as one type against a column of another forces a conversion that stops an index being used, and typing the value literally into a tool avoids it entirely. Compare the plan captured from the application with the plan from the tool; if they differ, this is your answer.

Second: the time is not in the database. Materialising a large result into tracked entities, waiting for a connection from an exhausted pool, or network latency all show up as elapsed time in the application while the statement itself is quick. Compare the duration EF Core logs for the command with the duration the database reports for the same statement.

A third worth knowing: the statement waited on a lock held by something else. That is intermittent and time-of-day dependent, and blocking reports from the database distinguish it from the other two.

What all three have in common is that the answer comes from comparing two measurements rather than from reading the query again. When the tool and the application disagree, the disagreement is the evidence.

Saved in this browser only.

End of the published lessons

That is everything written so far in EF Core

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.