EF Core Eager Loading Mastery — From Include Hell to Intentional Graph Loading
Most C# developers learn Include as:
var blogs = context.Blogs
.Include(b => b.Posts)
.ToList();
…and that’s it.
It works. The app shows related data. Everyone is happy.
Until:
- The query suddenly becomes slow in production.
- You add another
Includeand now you’re getting duplicated rows. - A filtered
Include“randomly ignores” your filter when tracking is enabled. - Auto‑includes load far more data than you expected.
- You try to debug a bug and can’t tell what EF Core is actually loading.
This post is your expert‑level guide to eager loading in EF Core — not just how to write Include, but when, why, and what it really does under the hood.
We’ll use EF Core 7/8+ style APIs, but the mental models will remain valid for future versions.
Table of Contents
- The Real Mental Model: What Eager Loading Actually Does
- Eager Loading vs Lazy vs Explicit — Choosing the Right Tool
-
IncludeBasics: One‑Level Navigation Loading - Multi‑Level Loading with
ThenInclude - Multiple Branches: Authors, Tags, Owners, and More
- Filtered Includes: Powerful, Subtle, and Easy to Misuse
- Tracking Pitfalls: Why Filtered Includes “Ignore” Your Filter
- Derived Types and Inheritance: Including on Polymorphic Graphs
- AutoInclude: Model‑Level Eager Loading (and When to Turn It Off)
- Performance Deep Dive: Single Query vs Split Query
- Designing Eager Loading for Aggregates (DDD View)
- Practical Checklist for Your Next EF Core Query
1. The Real Mental Model: What Eager Loading Actually Does
Eager loading is query‑time graph loading:
- You write a LINQ query over a root (
Blogs,Orders,Customers). - You mark navigation properties that should be populated in the same query using
Include/ThenInclude. - EF Core translates this to one or more SQL queries, constructs the entities, and fixes navigation references in memory.
Key points:
- Eager loading is about which navigations are fixed up when the result materializes.
- It is not a guarantee of “only one SQL query”. EF Core may use split queries to reduce cartesian explosion.
- In a tracking query, navigations may be populated even if you don’t explicitly
Includethem — because EF Core reuses already tracked entities.
If you keep this in mind, many “weird” behaviors suddenly make sense.
2. Eager Loading vs Lazy vs Explicit — Choosing the Right Tool
EF Core has three main strategies to load related data:
-
Eager loading (
Include,ThenInclude, AutoInclude)- Load a connected graph as part of the main query.
- Best when you know up front which related data you need.
-
Lazy loading (proxies / interceptors)
- Navigation access triggers additional queries.
- Convenient but can hide N+1 problems and make performance unpredictable.
-
Explicit loading (
context.Entry(entity).Collection(e => e.Posts).Load())- You manually load specific navigations at specific times.
- Great for surgical graph loading where timing matters.
This article is focused on eager loading, but serious EF Core design usually combines:
- Eager loading for read models / projections.
- Explicit loading for rare or conditional navigations.
- Lazy loading only in carefully controlled scenarios (or not at all in high‑scale systems).
3. Include Basics: One‑Level Navigation Loading
The simplest case is loading one collection or reference:
using (var context = new BloggingContext())
{
var blogs = context.Blogs
.Include(blog => blog.Posts)
.ToList();
}
Here EF Core:
- Generates SQL that joins
BlogsandPosts(or uses split queries if configured). - Materializes
BlogandPostentities. - Fixes up:
blog.Posts // populated
post.Blog // back‑reference populated in tracking queries
You can also include multiple unrelated navigations:
var blogs = context.Blogs
.Include(blog => blog.Posts)
.Include(blog => blog.Owner)
.ToList();
EF Core will merge the join pattern so that it doesn’t run completely separate queries for each include (unless split queries are enabled or required).
💡 Navigation Fixup
Even if you don’tIncludea navigation, EF Core can still populate it if the related entities are already being tracked by the sameDbContext. This is “fixup”: EF walks relationships between tracked entities and auto‑connects them.
This is why sometimes you see a navigation populated without an Include. It came from tracking state, not from the current query.
4. Multi‑Level Loading with ThenInclude
Real graphs are rarely shallow. You often want:
- Blogs → Posts → Author
- Blogs → Posts → Author → Photo
- Blogs → Owner → Photo
That’s where ThenInclude comes in:
var blogs = context.Blogs
.Include(blog => blog.Posts)
.ThenInclude(post => post.Author)
.ToList();
And going deeper:
var blogs = context.Blogs
.Include(blog => blog.Posts)
.ThenInclude(post => post.Author)
.ThenInclude(author => author.Photo)
.ToList();
You can also combine multiple roots and branches:
var blogs = context.Blogs
.Include(blog => blog.Posts)
.ThenInclude(post => post.Author)
.ThenInclude(author => author.Photo)
.Include(blog => blog.Owner)
.ThenInclude(owner => owner.Photo)
.ToList();
Behind the scenes EF Core builds an include tree:
- Root:
Blog- Collection:
Posts - Reference:
Author- Reference:
Photo
- Reference:
- Reference:
Owner - Reference:
Photo
- Collection:
EF’s query pipeline walks that tree when generating SQL and when fixing up navigations.
Multiple branches on the same collection
Suppose you want for each Post:
AuthorTags
You must start both paths from the root:
var blogs = context.Blogs
.Include(blog => blog.Posts)
.ThenInclude(post => post.Author)
.Include(blog => blog.Posts)
.ThenInclude(post => post.Tags)
.ToList();
EF Core merges these into a single include tree; you don’t get “double joins”, just a more complex SQL.
Include chains with only reference navigations
If all the navigations in the chain are references (or end with a single collection), EF Core allows a more compact pattern:
var blogs = context.Blogs
.Include(blog => blog.Owner.AuthoredPosts)
.ThenInclude(post => post.Blog.Owner.Photo)
.ToList();
This is useful when your domain has rich cycles and multiple back‑references.
5. Filtered Includes: Powerful, Subtle, and Easy to Misuse
Filtered includes allow you to filter/sort the included collection, not the root.
Supported operations on the included collection:
Where-
OrderBy/OrderByDescending -
ThenBy/ThenByDescending SkipTake
Example:
var filteredBlogs = context.Blogs
.Include(blog => blog.Posts
.Where(post => post.BlogId == 1)
.OrderByDescending(post => post.Title)
.Take(5))
.ToList();
Important details:
- The filter applies to the navigation collection, not the root (
Blogs). - EF Core translates this into SQL that restricts the joined collection, but returns all blogs.
- You can only apply one filter pipeline per navigation path. If you include the same collection multiple times, only one of them may have filters (unless they are identical).
Incorrect (filters on only one include):
var blogs = context.Blogs
.Include(blog => blog.Posts.Where(post => post.BlogId == 1))
.ThenInclude(post => post.Author)
.Include(blog => blog.Posts) // ❌ no filter here
.ThenInclude(post => post.Tags
.OrderBy(postTag => postTag.TagId)
.Skip(3))
.ToList();
Correct (identical filters applied to both branches):
var blogs = context.Blogs
.Include(blog => blog.Posts.Where(post => post.BlogId == 1))
.ThenInclude(post => post.Author)
.Include(blog => blog.Posts.Where(post => post.BlogId == 1))
.ThenInclude(post => post.Tags
.OrderBy(postTag => postTag.TagId)
.Skip(3))
.ToList();
EF Core internally merges these into one filtered include tree.
6. Tracking Pitfalls: Why Filtered Includes “Ignore” Your Filter
Filtered includes have a non‑obvious interaction with tracking.
Consider:
// First query: track many Orders
var orders = context.Orders
.Where(o => o.Id > 1000)
.ToList();
// Second query: filtered include on Customers
var customers = context.Customers
.Include(c => c.Orders.Where(o => o.Id > 5000))
.ToList();
In a tracking query:
- The second query says: “include Orders where
Id > 5000”. - BUT EF Core’s navigation fixup sees that the context already tracks Orders with
Id > 1000. - It attaches those orders to the
Customer.Orderscollection as well. - Result:
Customer.Orderscontains orders withId > 1000, not just> 5000.
This is by design: fixup guarantees navigations reflect the entities tracked in the context, not just the last query.
How to avoid surprises
If you rely on filtered includes:
- Prefer
AsNoTracking()for pure read scenarios:
var customers = context.Customers
.AsNoTracking()
.Include(c => c.Orders.Where(o => o.Id > 5000))
.ToList();
- Or use a fresh DbContext instance when issuing the filtered include query.
- Remember that in a tracking query, a filtered include also marks that navigation as loaded. EF Core will not try to re‑load it via lazy/explicit loading later, even if some items are “missing” due to your filter.
If you ignore this, you will spend hours debugging “why EF didn’t load all orders”, when in fact, EF is doing exactly what the tracking rules say.
7. Derived Types and Inheritance: Including on Polymorphic Graphs
Eager loading with inheritance is extremely common in rich domains.
Imagine:
public class Person
{
public int Id { get; set; }
public string Name { get; set; } = default!;
}
public class Student : Person
{
public School School { get; set; } = default!;
}
public class School
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public List<Student> Students { get; set; } = new();
}
public class SchoolContext : DbContext
{
public DbSet<Person> People { get; set; } = default!;
public DbSet<School> Schools { get; set; } = default!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<School>()
.HasMany(s => s.Students)
.WithOne(s => s.School);
}
}
You want to include School for only those Person that are Student.
EF Core lets you do this in several ways:
7.1 Cast pattern
var people = context.People
.Include(person => ((Student)person).School)
.ToList();
7.2 as operator
var people = context.People
.Include(person => (person as Student)!.School)
.ToList();
7.3 String‑based Include
var people = context.People
.Include("School")
.ToList();
In all cases, EF will:
- Load the
Schoolnavigation for entities that are actuallyStudent. - Leave it null for other
Personsubtypes.
🧠 Design tip
If your model uses inheritance heavily, it’s often worth having DbSet for the derived type as well (DbSet<Student>) and query it directly, so your includes become simpler and more intention‑revealing.
8. AutoInclude: Model‑Level Eager Loading
EF Core lets you configure navigations that are always eagerly loaded whenever the entity is queried:
modelBuilder.Entity<Theme>()
.Navigation(e => e.ColorScheme)
.AutoInclude();
Now:
using (var context = new BloggingContext())
{
var themes = context.Themes.ToList();
// ColorScheme is already loaded for all themes
}
Important behaviors:
- AutoInclude applies no matter how the entity appears in the query:
- As the main root (
context.Themes…) - Loaded via another navigation (
blog.Theme) - Included via another AutoInclude or explicit
Include
- As the main root (
- AutoInclude propagates to derived types: if derived entities have auto‑include navigations, they are loaded as well.
Turning AutoIncludes off for a specific query
Sometimes you want to override AutoInclude and load just the root:
var themes = context.Themes
.IgnoreAutoIncludes()
.ToList();
This suppresses user‑configured AutoIncludes for that query.
Note: navigations to owned or property types that are auto‑included by convention are not affected by IgnoreAutoIncludes() and will still be loaded.
AutoInclude is extremely powerful, but:
- It hides “magic loading” at the model level.
- Overuse can surprise consumers of your DbContext and impact performance.
Use it for:
- Small reference navigations that are almost always needed (metadata, configuration).
- Not for large collections or heavyweight graphs.
9. Performance Deep Dive: Single Query vs Split Query
Including collections can create huge cartesian products.
Example:
- 1,000 Blogs
- Each with 50 Posts
- Each Post with 10 Tags
A naive single SQL with joins over all three tables can:
- Materialize 1,000 × 50 × 10 = 500,000 rows.
- Blow up memory and significantly slow down the query.
EF Core introduced split queries to mitigate this:
- Single query:
optionsBuilder
.UseSqlServer(connectionString)
.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
- Split query:
optionsBuilder
.UseSqlServer(connectionString)
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
With split queries, EF Core:
- Sends multiple SQL queries (one per branch of the include tree).
- Materializes roots and related entities separately.
- Fixes up navigations in memory.
Trade‑offs:
-
SingleQuery
- ✅ Fewer round‑trips to the database
- ❌ Risk of cartesian explosion and high memory usage
-
SplitQuery
- ✅ Avoids massive cartesian products
- ❌ More round‑trips
- ❌ Slightly more complex query behavior
You can override per query:
var blogs = context.Blogs
.AsSplitQuery()
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ToList();
Rule of thumb:
- For small graphs,
SingleQueryis fine. - For large collections with nested includes, prefer
AsSplitQuery().
10. Designing Eager Loading for Aggregates (DDD View)
Instead of thinking “what should I include?”, think:
“What does this use case need as an aggregate?”
Example: Blog details screen
- Blog
- Owner
- Posts (latest 20)
- For each post: Author, Tags
An aggregate‑aware eager loading query might look like:
var blogDetails = await context.Blogs
.AsNoTracking()
.Where(b => b.Id == blogId)
.Select(b => new BlogDetailsDto
{
Id = b.Id,
Title = b.Title,
OwnerName = b.Owner.Name,
OwnerPhotoUrl = b.Owner.Photo.Url,
LatestPosts = b.Posts
.OrderByDescending(p => p.PublishedAt)
.Take(20)
.Select(p => new BlogPostDto
{
Id = p.Id,
Title = p.Title,
AuthorName = p.Author.Name,
Tags = p.Tags.Select(t => t.Name).ToList()
})
.ToList()
})
.FirstOrDefaultAsync(ct);
Notice:
- No explicit
Include— we project into DTOs. - EF Core still generates a graph‑aware SQL based on navigation usage.
- We use
OrderByDescending+Take(20)inside the collection to limit posts.
This approach often outperforms naive Include‑based loading because you:
- Load only the columns and entities you really need.
- Can reason about exactly what the API returns (no accidental extra data).
- Decouple your API contract from your persistence model.
Use navigation‑based projections for:
- Public API responses
- Read models / query side in CQRS
- High‑traffic endpoints
Use Include‑based eager loading more for:
- Internal tools
- Background jobs
- Scenarios where you want to keep full entities around and operate on them.
11. Diagnostics: Understanding What EF Core Is Actually Doing
For advanced work with eager loading, you should observe what EF Core is doing.
Enable logging of generated SQL
In your DbContextOptions:
optionsBuilder
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
Now you can see:
- How many SQL statements EF sends
- Whether includes are causing joins or split queries
- Whether filtered includes translated as you expected
Tag queries
To debug a specific query:
var blogs = context.Blogs
.TagWith("BlogDetailsQuery")
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ToList();
The tag appears as a comment in generated SQL, so you can quickly find it in logs and profilers.
Benchmark different shapes
Use BenchmarkDotNet to compare:
-
Includevs projection - SingleQuery vs SplitQuery
- Filtered include vs explicit separate queries
This turns “I think this is faster” into measured evidence.
12. Practical Checklist for Your Next EF Core Query
Before you add another .Include(...) line, run through this checklist:
-
Do I really need full entities, or just data?
- If just data → prefer projection to DTOs.
- If full entities for domain logic →
Includeis fine.
-
What is the aggregate root for this use case?
- Design your query around one root and its immediate graph.
- Avoid loading huge unrelated subgraphs just because the navigation exists.
-
Is this query tracking or no‑tracking?
- For read‑only endpoints:
AsNoTracking()(orAsNoTrackingWithIdentityResolution()). - Be aware that filtered includes + tracking can pull in more entities via fixup.
- For read‑only endpoints:
-
Are my includes creating huge cartesian products?
- Use
AsSplitQuery()for large collections with nested includes. - Consider splitting a monster query into two or more targeted queries.
- Use
-
Can I limit collections with filtered includes or
Take()?- For “latest posts”, “top 10 orders”, etc., always bound your collections.
-
Am I abusing AutoInclude?
- Use it for small, always‑needed references.
- Avoid it for big collections or rarely‑used navigations.
-
Have I logged and reviewed the SQL?
- If a query feeds a hot path (dashboard, homepage, etc.), inspect the SQL.
- Tweak includes and projections until you’re happy with both shape and performance.
Final Thoughts
Eager loading in EF Core is not just:
“Add
Includeuntil the UI stops throwing null references.”
Used intentionally, it becomes a precision tool for shaping data graphs:
-
Include/ThenIncludegive you control over navigations. - Filtered includes let you treat collections like first‑class citizens.
- AutoInclude enables model‑level defaults where they make sense.
- Split queries protect you from cartesian explosions.
- Projections give you DTOs tailored to your API, often faster and clearer.
Once you pair these techniques with:
- aggregate‑oriented design,
- good logging and measurement,
- and a healthy suspicion of “magic loading”,
you stop fighting EF Core and start collaborating with it.
Happy querying — and may your includes always load exactly what you meant, no more and no less. 🚀

Top comments (0)