Skip to main content
ANVISoftware Solutions
Lesson 9 of 17Intermediate16 min

Eager, Lazy and Explicit Loading

By the end of this lesson

Control when related data is fetched and at what cost.

A navigation property is the property that points from one entity to another: an Order has a Customer, a Customer has a list of Orders, a Department has a list of Employees. The database stores a foreign key column; your model gives you an object to walk to.

This lesson is about when that related object arrives. EF Core does not guess. You choose, and each choice sends a different number of queries and a different volume of data across the connection.

There are three ways to do it, and the differences between them are the whole subject.

The three strategies, in the order you will meet them:

Eager loading
You ask for the related data as part of the same query, using Include. One round trip to the database, and everything you named comes back with the main result.
Lazy loading
The related data is fetched the moment you first read the navigation property. No round trip until then, and a separate round trip each time it happens.
Explicit loading
You load the related data yourself, later, with a line of code that says so. The same extra round trip as lazy loading, except that you wrote it and can see it in the method.
Eager loading — one query for everything you named
C#
List<Order> orders = await context.Orders
    .Where(o => o.OrderDate >= from)
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .ToListAsync();

foreach (Order order in orders)
{
    // Already in memory. This loop touches the database zero times.
    Console.WriteLine(order.Customer.Name);

    foreach (OrderItem item in order.Items)
    {
        Console.WriteLine(item.Product.Name);
    }
}
  • Include names a navigation property to bring back with the main query. It reads like part of the query because that is what it becomes: a join.
  • ThenInclude continues from the previous Include. Items are order items, and Product hangs off each item, so reaching it needs the second step.
  • Include only applies to a query that returns entities. Once you project to a different shape with Select, Include no longer has anything to attach to. The next lesson covers that.
  • The loop is the payoff. With the data already loaded, property access is property access and nothing else.
What that Include produced (SQL Server, abbreviated)
SQL
SELECT [o].[Id], [o].[OrderDate], [o].[Status],
       [c].[Id], [c].[Name], [c].[Email],
       [i].[Id], [i].[OrderId], [i].[ProductId], [i].[Quantity], [i].[UnitPrice],
       [p].[Id], [p].[Name], [p].[Sku]
FROM [Orders] AS [o]
INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
LEFT JOIN [OrderItems] AS [i] ON [o].[Id] = [i].[OrderId]
LEFT JOIN [Products] AS [p] ON [i].[ProductId] = [p].[Id]
WHERE [o].[OrderDate] >= @__from_0
ORDER BY [o].[Id], [c].[Id], [i].[Id];
  • One query, which is the point of eager loading. Now look at what the row shape costs: an order with twelve items comes back as twelve rows, and every one of those rows repeats the order columns and the customer columns.
  • EF Core removes the duplication when it builds the objects, so you receive one Order holding twelve Items. The duplication happened on the network, not in your code, which is exactly why it is easy to miss.
  • The ORDER BY is not for your benefit. EF Core needs rows belonging to the same parent grouped together so it can assemble the object graph in one pass.
  • Run your own query and read your own log. Provider and version change this text.
Opting into lazy loading, and loading explicitly instead
C#
// --- Lazy loading: configured once, then invisible at the call site ---
// Needs the Microsoft.EntityFrameworkCore.Proxies package, and every
// navigation property must be virtual so the generated proxy can override it.
options.UseLazyLoadingProxies();

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public virtual Customer Customer { get; set; } = null!;
    public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

// --- Explicit loading: the same round trip, written down ---
Order order = await context.Orders.FirstAsync(o => o.Id == orderId);

await context.Entry(order).Reference(o => o.Customer).LoadAsync();
await context.Entry(order).Collection(o => o.Items).LoadAsync();

// Explicit loading can also load part of a collection
await context.Entry(order).Collection(o => o.Items)
    .Query()
    .Where(i => i.Quantity > 1)
    .LoadAsync();
  • With proxies enabled, reading order.Customer sends a query the first time it happens. Nothing at the call site indicates that, which is both the appeal and the problem.
  • Explicit loading does the same work with the cost on the page. Entry(order) returns EF Core's tracking entry for that object, Reference is for a single related entity, and Collection is for a list.
  • Query() on a collection gives you a queryable for the related rows, so you can filter before loading instead of pulling the whole collection into memory.
  • Explicit loading needs the entity to be tracked. It will not work on a result from a no-tracking query, because there is no entry to ask.

Summary

  • Eager loading with Include fetches related data in the same query, as a join
  • Lazy loading fetches on first access, which hides a round trip behind ordinary property access
  • Explicit loading costs the same round trip as lazy loading but stays visible in the code
  • A joined Include repeats parent columns once per child row; AsSplitQuery trades that for more statements
  • Pick a strategy from measured row counts and latency, not from habit

Practice

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

Think about it

Count the rows before you write the query

You load one Department that has 40 Employees, and you ThenInclude each employee's Manager, who is also an Employee.

Roughly how many rows come back, and which columns are repeated? What does that tell you about when Include is cheap and when it is not?

Show solution

About 40 rows, one per employee. The department columns are repeated on all 40 of them, and a manager shared by several employees has their columns repeated once per report.

This shape is fine, because there is a single collection. The grain of the result is the collection at the bottom of the include chain, so one collection means one row per child.

The cost appears when a second independent collection joins in. Include the department's Projects as well and you get 40 employees multiplied by the project count, with almost all of it duplicated. That is the point at which AsSplitQuery, or a projection, is worth measuring.

Try it yourself

Compare one query against several

Take a query with two collection Includes on the same parent. Log the SQL and note how many rows the database returned.

Add AsSplitQuery() and run it again. Note the number of statements and the total rows.

Which is faster against your data, and would your answer change if the database were in another region?

Show solution

The single query sends one statement and many duplicated rows. The split version sends one statement per collection and far fewer rows.

Which wins depends on two numbers: how much duplication the join creates, and how long a round trip takes. High duplication favours splitting. High latency favours a single query.

The reason to run it rather than reason about it is that both numbers are properties of your deployment, not of EF Core.

C#
List<Order> orders = await context.Orders
    .Include(o => o.Items)
    .Include(o => o.Shipments)
    .AsSplitQuery()
    .ToListAsync();

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Lazy loading is enabled. You load 200 orders, each for a different customer that is not already in memory, then read order.Customer.Name for every one. How many queries reach the database?
What is the main cost of a query with two collection Includes on the same parent?

Saved in this browser only.