Eager Loading of Child Entities in EF Core: A Beginner's Guide
Learn eager loading in EF Core with Include and ThenInclude. Load child entities in one query, avoid the N+1 problem, and use filtered Include with simple examples.
Filling one shopping bag, not many
Imagine your mother sends you to the local kirana shop. She says: "Bring rice, dal, and oil."
You have two ways to do this.
The slow way: you walk to the shop, buy only rice, and walk home. Then you go back for dal, and walk home again. Then a third trip just for oil. Three full trips for three items. Your legs are tired and the whole day is gone.
The smart way: you go to the shop once, put rice, dal, and oil all in one bag, and walk home. One trip. Done.
Talking to a database is just like walking to the shop. Each trip costs time. Eager loading in EF Core is the smart way. It lets you grab a parent record and all its related child records in one trip, in one neat bag. In this guide you will learn exactly how to do that with two friendly methods: Include and ThenInclude.
What "child entities" means
Before we load anything, let us agree on words.
In EF Core, your data is shaped as entities (think: classes that map to tables). Entities are connected to each other. A Blog has many Posts. A Post has one Author. A Post has many Comments.
These connections are called navigation properties. When you stand on a Blog, the Posts are its children. When you stand on a Post, the Comments are its children — and the Blog is its parent. Children of children are sometimes called grandchildren.
Here are the matching C# classes so the rest of the guide makes sense.
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Post> Posts { get; set; } = new(); // children
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
public Author Author { get; set; } = null!; // grandchild
public List<Comment> Comments { get; set; } = new(); // grandchildren
}
public class Comment
{
public int Id { get; set; }
public string Text { get; set; } = "";
public int PostId { get; set; }
}The problem eager loading solves
Suppose you fetch all blogs without asking for their posts:
var blogs = await context.Blogs.ToListAsync();
foreach (var blog in blogs)
{
Console.WriteLine(blog.Name);
Console.WriteLine(blog.Posts.Count); // uh oh
}What is in blog.Posts? Nothing useful. EF Core only loaded the blogs. The Posts list is empty because you never asked for it.
To fix this badly, some people loop and run a new query for each blog. That is the famous N+1 problem: 1 query for the blogs, then N more queries — one for each blog's posts. With 100 blogs that is 101 trips to the database. Slow, just like walking to the shop 101 times.
The N+1 problem
Steps
Get blogs
1 query
Loop blog 1
+1 query
Loop blog 2
+1 query
Loop blog N
+1 query = N+1 total
Eager loading is the cure. You tell EF Core up front: "While you are fetching blogs, please also bring their posts." One trip, one bag.
Include: load the children
The method that does eager loading is Include. You pass it a small arrow function that points at the navigation property you want.
var blogs = await context.Blogs
.Include(b => b.Posts) // bring the posts too
.ToListAsync();Now blog.Posts is full. EF Core wrote SQL that joined Blogs to Posts, so both came back together. No loop. No N+1.
Think of Include as the line "and please also put X in my bag." Each thing you want, you Include.
ThenInclude: go one level deeper
Include loads a direct child. But what if you want a child of the child — a grandchild? For that you use ThenInclude.
Say you want every blog, with its posts, and for each post you also want the author. The author is a child of the post, not of the blog. So you start with Include(b => b.Posts) and then continue down the chain with ThenInclude(p => p.Author).
var blogs = await context.Blogs
.Include(b => b.Posts) // step 1: blog -> posts
.ThenInclude(p => p.Author) // step 2: post -> author
.ToListAsync();Read it like a path you walk: Blog → Posts → Author. Each ThenInclude continues from where the last Include (or ThenInclude) left you standing.
You can keep going deeper. If an author had a Photo, you would add another ThenInclude(a => a.Photo).
Loading two children at the same level
Sometimes a post has both an author and comments, and you want both. You cannot reach both with a single chain, because after the first ThenInclude you have walked down to the author. To grab another branch, you start a fresh path from the root with a new Include.
var blogs = await context.Blogs
.Include(b => b.Posts)
.ThenInclude(p => p.Author) // path: Blog -> Posts -> Author
.Include(b => b.Posts)
.ThenInclude(p => p.Comments) // path: Blog -> Posts -> Comments
.ToListAsync();Notice we wrote Include(b => b.Posts) twice. That is normal and correct. EF Core is smart enough to load Posts only once and then attach both branches. You are simply describing two separate paths through the tree.
Two paths from the same root
Steps
Path A
Blog to Posts to Author
Path B
Blog to Posts to Comments
Merge
EF Core loads Posts once
Filtered Include: load only some children
A common worry: "But I do not want all the posts. I only want the published ones." Good news — EF Core lets you filter the children right inside Include. This is called filtered Include.
Inside the lambda, you can use Where, OrderBy, OrderByDescending, Skip, and Take on a collection navigation.
var blogs = await context.Blogs
.Include(b => b.Posts
.Where(p => p.IsPublished) // only published posts
.OrderByDescending(p => p.Id) // newest first
.Take(5)) // just the top 5
.ToListAsync();This loads each blog with at most its 5 newest published posts. Very handy for "show a preview" screens.
Two rules to remember:
- Filtered Include works on collection navigations (lists), not on single references like
Author. - If you
Includethe same navigation more than once, every one of those includes must use the same filter, or EF Core will complain.
A quick comparison of loading strategies
Eager loading is one of three ways EF Core can bring in related data. Here is how they sit side by side.
| Strategy | When data loads | How you ask | Risk |
|---|---|---|---|
| Eager loading | In the same query as the parent | Include / ThenInclude | Loading more than you need |
| Lazy loading | Automatically, when you touch the property | Virtual navigation props + proxies | Hidden N+1 problem |
| Explicit loading | Later, when you choose | Entry(...).Collection(...).Load() | More code to write by hand |
For most apps, eager loading is the clear, safe default. You see exactly what you load, in one place, with one trip.
Watch out: the cartesian explosion
Eager loading is great, but there is one trap. When you Include two or more collections in a single query, the database joins everything together and multiplies the rows. A blog with 10 posts and 10 tags can return 100 rows instead of 20, because every post gets paired with every tag. This is called cartesian explosion, and it can make queries slow.
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Tags) // two collections = rows multiply
.AsSplitQuery() // ask EF Core to split into separate queries
.ToListAsync();The fix is AsSplitQuery(). It tells EF Core to run one query per collection instead of one giant join. Fewer duplicated rows travel across the wire. Use it when you include more than one collection. (For a single collection, a normal single query is usually fine.)
| Situation | Best choice |
|---|---|
| Include one collection | Single query (default) |
| Include two or more collections | AsSplitQuery() |
| Read-only data, no edits | Add AsNoTracking() |
Read-only? Add AsNoTracking
When you only want to show data and never save changes to it, add AsNoTracking(). EF Core then skips its change-tracking bookkeeping, which makes the query faster and lighter on memory. It pairs perfectly with eager loading on report and list screens.
var blogs = await context.Blogs
.AsNoTracking() // read-only, faster
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ToListAsync();Only skip tracking when you are sure you will not edit and save these entities back.
Putting it all together
Here is a realistic query that loads blogs, their published posts, each post's author, and each post's comments — all in one well-shaped trip.
var blogs = await context.Blogs
.AsNoTracking()
.Include(b => b.Posts.Where(p => p.IsPublished))
.ThenInclude(p => p.Author)
.Include(b => b.Posts)
.ThenInclude(p => p.Comments)
.AsSplitQuery()
.ToListAsync();This reads almost like English: "Give me blogs, with their published posts and each post's author, and also their posts' comments, and split it up so the rows do not explode."
A complete eager-loading query
Steps
AsNoTracking
read-only speed
Include Posts
load children
ThenInclude
load grandchildren
AsSplitQuery
avoid explosion
Common mistakes to avoid
- Forgetting Include. If a navigation looks empty, you probably did not
Includeit. Lists come back empty rather than throwing an error, so it is easy to miss. - Using string Include for typos. There is a string overload like
Include("Posts.Author"), but the lambda version is safer because the compiler catches mistakes for you. - Over-including. Do not
Includeten levels of data "just in case." Load only what the screen actually shows. Extra includes mean extra rows and slower queries. - Mixing different filters on the same navigation. Every filtered
Includeof the same navigation must use the identical filter. - Ignoring two-collection explosions. Whenever you include more than one collection, think about
AsSplitQuery().
Quick recap
- Eager loading loads a parent and its related children together in one trip, like buying everything in one shop visit.
- Use
Includeto load a direct child navigation, such as a blog's posts. - Use
ThenIncludeto go deeper and load grandchildren, like each post's author. - To load two branches, write a separate
Includepath from the root for each one. - Filtered Include lets you load only some children with
Where,OrderBy,Skip, andTake. - Eager loading avoids the N+1 problem, which happens when you accidentally run one query per parent.
- Including two or more collections can cause cartesian explosion; fix it with
AsSplitQuery(). - Add
AsNoTracking()for read-only screens to make queries faster.
References and further reading
- Eager Loading of Related Data — EF Core (Microsoft Learn)
- Loading Related Data — EF Core (Microsoft Learn)
- Single vs. Split Queries — EF Core (Microsoft Learn)
- Eager, Lazy and Explicit Loading with EF Core — JetBrains .NET Tools Blog
- Eager Loading using Include & ThenInclude — TekTutorialsHub
Related Posts
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
EF Core Query Optimization: From 30 Seconds to 30 Milliseconds
Learn EF Core query optimization step by step: fix N+1 queries, use projections, AsNoTracking, indexes, and compiled queries to turn a 30-second query into 30ms.
Top 10 Mistakes Developers Make in EF Core (and How to Fix Them)
The 10 most common EF Core mistakes that slow your app down — N+1 queries, missing AsNoTracking, ToList too early, lazy loading, and more — with simple fixes.
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.