EF Core offers many options to fit your use case, key is utilizing its full potential.
One of its capabilities is managing related data through different loading strategies.
EF Core supports 3 ways to load related entities:
- Eager Loading - Fetches related data as part of the initial query.
- Explicit Loading - Loads related data manually only when needed.
- Lazy Loading - Defers loading of related data until it’s actually accessed.
In today's post we are going to have a look at Eager loading.
Eager Loading
Eager loading is a strategy where EF Core retrieves related entities together with the main entity in a single query.
This helps ensure all required data is available right away, which avoids the N+1 query problem and optimizes for fewer database round trips.
On the other hand, it's not great if you are fetching a large amount of data and only occasially utilizing it when dealing with complex objects where other strategies might be a better fit.
For this example, I’ve created few simple entities:
public class Blog
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public List<Tag> Tags { get; set; } = [];
public List<Post> Posts { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; }
public Blog Blog { get; set; }
public int BlogId { get; set; }
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public Blog Blog { get; set; }
public int BlogId { get; set; }
public List<Comment> Comments { get; set; }
}
public class Comment
{
public int Id { get; set; }
public string Content { get; set; }
public int PostId { get; set; }
public Post Post { get; set; } = null!;
}
The Blog entity includes both Posts and Tags, and each Post can have multiple Comments.
Include
You can use the Include method to specify related data to be included in query results:
app.MapGet("include/", async (ApplicationDbContext dbContext) =>
{
var blogs = await dbContext.Blogs
.Include(b => b.Posts)
.ToListAsync();
return Results.Ok(blogs);
});
The returned blogs will have their Posts property populated with related posts:
[
{
"id": 1,
"createdAt": "2025-02-20T02:47:01.328624Z",
"tags": [],
"posts": [
{
"id": 1,
"titles": "Porro nam ipsam itaque consequatur.",
"blog": null,
"blogId": 1,
"comments": null
},
{
"id": 2,
"titles": "Id accusamus cupiditate eveniet fuga ullam consequatur provident molestiae.",
"blog": null,
"blogId": 1,
"comments": null
}
]
},
{
"id": 2,
"createdAt": "2024-08-23T03:24:11.221158Z",
"tags": [],
"posts": [
{
"id": 4,
"title": "Sit maxime eaque est earum ut possimus distinctio deleniti.",
"blog": null,
"blogId": 2,
"comments": null
}
]
}
]
Multiple Includes
You can include related data from multiple relationships in a single query:
app.MapGet("multi-include/", async (ApplicationDbContext dbContext) =>
{
var blogs = await dbContext.Blogs
.Include(b => b.Tags)
.Include(b => b.Posts)
.ToListAsync();
return Results.Ok(blogs);
});
Eager loading multiple collection navigations in a single query may cause performance issues also known as a cartesian explosion.
To learn more about it, check my blog post where we adressed this issue: Split Queries
Filtered Include
When using Include, you can also apply enumerable operations to filter and sort the results:
using (var context = new BloggingContext())
{
var filteredBlogs = await context.Blogs
.Include(
blog => blog.Posts
.Where(post => post.BlogId == 1)
.OrderByDescending(post => post.Title)
.Take(5))
.ToListAsync();
}
Each included navigation allows only one unique set of filter operations.
Supported operations are:
- Where
- OrderBy
- OrderByDescending
- ThenBy
- ThenByDescending
- Skip
- Take
NOTE: When using tracking queries, results of filtered include may be unexpected due to navigation fixup.
Basically, entities already tracked can appear even if they don't match the filter, to prevent this use AsNotTracking() or a fresh DbCotnext to avoid this.
For more information, check the official documentation: Filtered Include
ThenInclude
You can use ThenInclude to load multiple levels of related data by chaining calls.
It allows you to include data from different relationship depths in a single query.
var blogs = await context.Blogs
.Include(blog => blog.Owner.AuthoredPosts)
.ThenInclude(post => post.Blog.Owner.Photo)
.ToListAsync();
It doesn't mean you'll get redundant joins, in most cases EF will combine the joins when generating SQL.
AutoInclude
EF Core also supports automatic eager loading using the [AutoInclude] attribute or model configuration:
modelBuilder.Entity<Blog>()
.Navigation(b => b.Posts)
.AutoInclude();
This ensures related data is always loaded without having to specify Include() in every query.
If for a particular query you don't want to load the related data through a navigation, which is configured at the model level to be auto-included, you can use the IgnoreAutoIncludes method in your query.
var blogs = await context.Blogs
.IgnoreAutoIncludes()
.ToListAsync();
Using this method will stop loading all the navigations configured as auto-include.
NOTE: Navigations to owned types are also configured as auto-included by convention and using the IgnoreAutoIncludes doesn't stop them from being included.
Conclusion
EF Core offers flexible strategies to efficiently load related data, with eager loading being a powerful option to fetch all needed entities in one query and avoid multiple database round trips.
While eager loading simplifies data retrieval, it’s important to be mindful of potential performance pitfalls like cartesian explosion or tracking quirks with filtered includes.
Features like ThenInclude and AutoInclude provide further control over complex relationship loading, helping you tailor queries to fit your app’s specific needs.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
