DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

EF Core Eager Loading Mastery — From `Include` Hell to Intentional Graph Loading

EF Core Eager Loading Mastery — From  raw `Include` endraw  Hell to Intentional Graph Loading

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();
Enter fullscreen mode Exit fullscreen mode

…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 Include and 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

  1. The Real Mental Model: What Eager Loading Actually Does
  2. Eager Loading vs Lazy vs Explicit — Choosing the Right Tool
  3. Include Basics: One‑Level Navigation Loading
  4. Multi‑Level Loading with ThenInclude
  5. Multiple Branches: Authors, Tags, Owners, and More
  6. Filtered Includes: Powerful, Subtle, and Easy to Misuse
  7. Tracking Pitfalls: Why Filtered Includes “Ignore” Your Filter
  8. Derived Types and Inheritance: Including on Polymorphic Graphs
  9. AutoInclude: Model‑Level Eager Loading (and When to Turn It Off)
  10. Performance Deep Dive: Single Query vs Split Query
  11. Designing Eager Loading for Aggregates (DDD View)
  12. 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 Include them — 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:

  1. 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.
  2. Lazy loading (proxies / interceptors)

    • Navigation access triggers additional queries.
    • Convenient but can hide N+1 problems and make performance unpredictable.
  3. 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();
}
Enter fullscreen mode Exit fullscreen mode

Here EF Core:

  1. Generates SQL that joins Blogs and Posts (or uses split queries if configured).
  2. Materializes Blog and Post entities.
  3. Fixes up:
   blog.Posts       // populated
   post.Blog        // back‑reference populated in tracking queries
Enter fullscreen mode Exit fullscreen mode

You can also include multiple unrelated navigations:

var blogs = context.Blogs
    .Include(blog => blog.Posts)
    .Include(blog => blog.Owner)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

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’t Include a navigation, EF Core can still populate it if the related entities are already being tracked by the same DbContext. 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();
Enter fullscreen mode Exit fullscreen mode

And going deeper:

var blogs = context.Blogs
    .Include(blog => blog.Posts)
        .ThenInclude(post => post.Author)
            .ThenInclude(author => author.Photo)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Behind the scenes EF Core builds an include tree:

  • Root: Blog
    • Collection: Posts
    • Reference: Author
      • Reference: Photo
    • Reference: Owner
    • Reference: Photo

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:

  • Author
  • Tags

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  • Skip
  • Take

Example:

var filteredBlogs = context.Blogs
    .Include(blog => blog.Posts
        .Where(post => post.BlogId == 1)
        .OrderByDescending(post => post.Title)
        .Take(5))
    .ToList();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.Orders collection as well.
  • Result: Customer.Orders contains orders with Id > 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:

  1. Prefer AsNoTracking() for pure read scenarios:
   var customers = context.Customers
       .AsNoTracking()
       .Include(c => c.Orders.Where(o => o.Id > 5000))
       .ToList();
Enter fullscreen mode Exit fullscreen mode
  1. Or use a fresh DbContext instance when issuing the filtered include query.
  2. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

7.2 as operator

var people = context.People
    .Include(person => (person as Student)!.School)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

7.3 String‑based Include

var people = context.People
    .Include("School")
    .ToList();
Enter fullscreen mode Exit fullscreen mode

In all cases, EF will:

  • Load the School navigation for entities that are actually Student.
  • Leave it null for other Person subtypes.

🧠 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();
Enter fullscreen mode Exit fullscreen mode

Now:

using (var context = new BloggingContext())
{
    var themes = context.Themes.ToList();
    // ColorScheme is already loaded for all themes
}
Enter fullscreen mode Exit fullscreen mode

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
  • 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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
  • Split query:
  optionsBuilder
      .UseSqlServer(connectionString)
      .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
Enter fullscreen mode Exit fullscreen mode

With split queries, EF Core:

  1. Sends multiple SQL queries (one per branch of the include tree).
  2. Materializes roots and related entities separately.
  3. 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();
Enter fullscreen mode Exit fullscreen mode

Rule of thumb:

  • For small graphs, SingleQuery is 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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

  • Include vs 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:

  1. Do I really need full entities, or just data?

    • If just data → prefer projection to DTOs.
    • If full entities for domain logic → Include is fine.
  2. 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.
  3. Is this query tracking or no‑tracking?

    • For read‑only endpoints: AsNoTracking() (or AsNoTrackingWithIdentityResolution()).
    • Be aware that filtered includes + tracking can pull in more entities via fixup.
  4. 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.
  5. Can I limit collections with filtered includes or Take()?

    • For “latest posts”, “top 10 orders”, etc., always bound your collections.
  6. Am I abusing AutoInclude?

    • Use it for small, always‑needed references.
    • Avoid it for big collections or rarely‑used navigations.
  7. 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 Include until the UI stops throwing null references.”

Used intentionally, it becomes a precision tool for shaping data graphs:

  • Include / ThenInclude give 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)