Skip to main content
SEMastery
Data Accessbeginner

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.

11 min readUpdated May 3, 2026

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.

Figure 1: A simple family tree of entities. Blog is the parent, Post is the child, Comment and Author are 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

Get blogs
Loop blog 1
Loop blog 2
Loop blog N

Steps

1

Get blogs

1 query

2

Loop blog 1

+1 query

3

Loop blog 2

+1 query

4

Loop blog N

+1 query = N+1 total

One query for parents, then one extra query per parent. The trips pile up fast.

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.

Figure 2: Without Include the posts stay empty. With Include they arrive in the same trip.

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).

Figure 3: Include moves to the child, ThenInclude continues to the grandchild and beyond.

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

Blog
Posts
Author
Comments

Steps

1

Path A

Blog to Posts to Author

2

Path B

Blog to Posts to Comments

3

Merge

EF Core loads Posts once

To load two grandchildren, describe each full path from the parent.

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 Include the 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.

StrategyWhen data loadsHow you askRisk
Eager loadingIn the same query as the parentInclude / ThenIncludeLoading more than you need
Lazy loadingAutomatically, when you touch the propertyVirtual navigation props + proxiesHidden N+1 problem
Explicit loadingLater, when you chooseEntry(...).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.

Figure 4: The three loading strategies decide when related data arrives.

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.)

SituationBest choice
Include one collectionSingle query (default)
Include two or more collectionsAsSplitQuery()
Read-only data, no editsAdd 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

AsNoTracking
Include Posts
ThenInclude Author
Include Comments
AsSplitQuery

Steps

1

AsNoTracking

read-only speed

2

Include Posts

load children

3

ThenInclude

load grandchildren

4

AsSplitQuery

avoid explosion

Each piece adds one clear instruction to the query.

Common mistakes to avoid

  • Forgetting Include. If a navigation looks empty, you probably did not Include it. 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 Include ten 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 Include of 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 Include to load a direct child navigation, such as a blog's posts.
  • Use ThenInclude to go deeper and load grandchildren, like each post's author.
  • To load two branches, write a separate Include path from the root for each one.
  • Filtered Include lets you load only some children with Where, OrderBy, Skip, and Take.
  • 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

Related Posts