Chapter 10 · Loading Related Data: Eager, Explicit, Lazy, Filtered Include, and Split Queries
Explicit Loading and Targeted Relationship Queries for Conditional Workflows
Use explicit loading and navigation Query() for conditional workflows while exposing tracked-state effects, connection use, server-side aggregates, and N+1 failure modes.
Learning outcomes
A dispatcher list usually needs only work-order summary rows.
Notes are needed only after an operator opens a detail panel; a
count may be needed without any note entities. Explicit loading
lets the application make that decision visible. The danger is
moving the query into a loop and accidentally creating N+1 round
trips. This lesson distinguishes LoadAsync from
Query() and shows set-based repairs.
Use Entry(...).Reference(...).LoadAsync and Collection(...).LoadAsync intentionally.
Use navigation Query() to filter or aggregate related rows without materializing the whole collection.
Explain how explicit loading interacts with tracking and navigation fixup.
Make command count and connection usage observable.
Reproduce explicit-loading N+1 in a loop and repair it with set-based queries/projections.
Choose explicit loading only when the conditional workflow justifies its extra statement.
Mandatory labs use .NET 10 SDK 10.0.400, .NET runtime 10.0.11, EF Core/SQLite 10.0.11, the disposable servicehub-lab.db, deterministic seed data, and no paid tooling. SQL Server/PostgreSQL notes are comparative only unless explicitly labeled.
1. Explicit loading means the code names the I/O point
With explicit loading, the principal is already tracked and application code later asks EF to load a specific navigation. That is different from lazy loading, where property access triggers the query implicitly. Explicit loading can make conditional workflows readable because the database call is visible and awaitable.
var workOrder = await db.WorkOrders .SingleAsync(w => w.Id == id, ct);await db.Entry(workOrder) .Reference(w => w.AssignedTechnician) .LoadAsync(ct);await db.Entry(workOrder) .Collection(w => w.Notes) .LoadAsync(ct);
Each LoadAsync can execute another query. The
context remains the same unit of work, so newly materialized
related entities are tracked and fixup populates both ends of
configured relationships.
2. Query() lets the database answer without populating everything
var workOrder = await db.WorkOrders.SingleAsync(w => w.Id == id, ct);var noteCount = await db.Entry(workOrder) .Collection(w => w.Notes) .Query() .CountAsync(ct);var newestNotes = await db.Entry(workOrder) .Collection(w => w.Notes) .Query() .OrderByDescending(n => n.Id) .Take(3) .AsNoTracking() .Select(n => new { n.Id, n.Text }) .ToListAsync(ct);
CountAsync translates to a database aggregate; it
does not materialize every note merely to count them.
Query() is therefore a useful bridge from
relationship metadata back to normal composable LINQ.
SELECT COUNT(*)FROM work_order_notes AS nWHERE n.work_order_id = @__id_0;
3. IsLoaded and fixup are state, not just query text
Calling LoadAsync marks the navigation loaded.
Querying related entities through Query() may
materialize matching entities and fix them into a tracked graph,
but a partial filtered query does not magically mean “the full
navigation is complete.” Inspect the entry state when
correctness depends on it.
var notesEntry = db.Entry(workOrder).Collection(w => w.Notes);Console.WriteLine($"Before: {notesEntry.IsLoaded}");await notesEntry.LoadAsync(ct);Console.WriteLine($"After: {notesEntry.IsLoaded}");Console.WriteLine(db.ChangeTracker.DebugView.ShortView);
4. Deliberately wrong: explicit loading inside a result loop
var workOrders = await db.WorkOrders .OrderBy(w => w.Id) .Take(100) .ToListAsync(ct); // 1 commandforeach (var workOrder in workOrders){ await db.Entry(workOrder) .Collection(w => w.Notes) .LoadAsync(ct); // up to 100 more commands}
The loop is easy to read and expensive on a remote database: one root query plus one child query per principal. This is the classic N+1 pattern. The repair depends on what the caller actually needs.
var rows = await db.WorkOrders .OrderBy(w => w.Id) .Take(100) .Select(w => new { w.Id, w.WorkOrderNumber, NoteCount = w.Notes.Count }) .AsNoTracking() .ToListAsync(ct);
var ids = workOrders.Select(w => w.Id).ToArray();await db.Set<WorkOrderNote>() .Where(n => ids.Contains(n.WorkOrderId)) .LoadAsync(ct); // fixup distributes notes to tracked WorkOrders
Repair B intentionally relies on tracking/fixup and should be verified against EF Core 10's parameterized-collection translation plus provider parameter limits for very large key sets.
5. Connection lifetime is shorter than context lifetime
A DbContext usually opens a database connection
when a command needs it and returns/closes it afterward; keeping
a context alive does not imply one continuously open physical
connection. Explicit loading adds commands and therefore
connection use/round trips. Driver-level connection pooling is
separate from DbContext pooling and separate from related-data
loading strategy.
Enable EF Database.Command logs and count command starts/completions. If diagnosing pool pressure, add ADO.NET/provider metrics as well; DbContext DebugView cannot prove physical connection-pool behavior.
6. Conditional workflow example
var workOrder = await db.WorkOrders .SingleAsync(w => w.Id == id, ct);if (request.IncludeInternalNotes && authorization.CanViewInternalNotes){ await db.Entry(workOrder) .Collection(w => w.Notes) .Query() .OrderByDescending(n => n.Id) .Take(20) .LoadAsync(ct);}
Loading is not authorization. The authorization decision must
exist independently; query filters/Includes do not replace
access control. Also note that partially loading a tracked
collection and then serializing the entity can mislead consumers
about completeness. Prefer a DTO with an explicit
InternalNotes field when crossing an API boundary.
7. Hands-on lab: measure command count, not intuition
- Seed 30 work orders with deterministic note counts.
- Load the 30 roots and explicitly load Notes in a loop; capture command logs and count them.
-
Replace the loop with a projection containing
Notes.Count; verify the aggregate stays server-side. -
Repeat with the set-based
ids.Contains(...)tracked-load repair and inspect navigation fixup. -
Record
IsLoadedbefore/after fullLoadAsync. -
Cancel a long-running/repeated operation with a
CancellationTokenand verify cancellation is awaited before reusing the context.
Check your understanding
- What makes explicit loading “explicit”?
- How can you count related rows without materializing them?
- Why is LoadAsync in a loop dangerous?
- How can tracked set-based loading populate each principal collection?
- Does a DbContext keep one database connection open for its entire lifetime by default?
- Can conditional loading serve as authorization?
Review the answers
Application code calls Load/LoadAsync or executes the navigation Query() at a visible I/O point.
Use Collection(...).Query().CountAsync(), which translates to a database aggregate.
It can create one additional query per principal—the N+1 pattern.
Load all matching dependents in one query; navigation fixup connects them to already tracked principals.
No. Connections are normally opened for commands and returned/closed afterward; connection pooling is a separate layer.
No. Authorization must be enforced independently; loading strategy only controls data access shape/timing.
8. Production judgment and bridge
Explicit loading is appropriate when the condition for fetching
related data is known only after the principal is loaded and the
extra command is acceptable. Prefer Query() for
aggregates and bounded subsets. Never hide a loop of
relationship loads behind a “clean” method without command-count
tests. Lesson 5 examines lazy loading, where that same extra
command can be triggered by ordinary property access and become
even harder to see.
Authoritative references
- Explicit Loading of Related Data - EF Core — LoadAsync and navigation Query() examples.
- Loading Related Data - EF Core — eager, explicit, and lazy loading definitions.
- Changing Foreign Keys and Navigations - EF Core — navigation fixup.
- Efficient Querying - EF Core — round trips, N+1, query efficiency.
- DbContext Lifetime, Configuration, and Initialization — unit-of-work and async discipline.