Loading related data and the N+1 problem
Lazy, eager and explicit loading, how N+1 happens, and the cartesian explosion that a naive fix walks into.
After this lesson you can
- Name EF Core's three loading strategies and when each queries
- Count the queries a loop over lazy navigations issues
- Explain cartesian explosion and when AsSplitQuery fixes it
- Reach for a projection when you only need a few columns
A navigation property such as blog.Posts is not loaded just because
the blog was. EF Core gives you three ways to load it:
- Eager:
db.Blogs.Include(b => b.Posts)loads it in the same query. - Explicit:
await db.Entry(blog).Collection(b => b.Posts).LoadAsync()issues a query when you ask. - Lazy: with
UseLazyLoadingProxies()(theMicrosoft.EntityFrameworkCore.Proxiespackage) andvirtualnavigations, the first access toblog.Postsissues a query.
Without any of these, the collection is empty, or holds only the posts this context happens to be tracking already. That is a correctness bug rather than a performance one: the code runs and reports "no posts".
N+1
var blogs = await db.Blogs.ToListAsync(); // 1 query
foreach (var blog in blogs)
total += blog.Posts.Count; // +1 query per blog, lazily
One query for the list, then one per row: N+1 round trips. Each is fast, so it passes in development with ten rows and collapses in production with ten thousand. The example is a model: a fake database that only counts the queries each approach would send.
public class FakeDb{ public int Queries; readonly Dictionary<int, int> postCounts = new() { [1] = 3, [2] = 0, [3] = 5, [4] = 1 }; public List<int> BlogIds() { Queries++; return postCounts.Keys.ToList(); } public int PostsFor(int blogId) { Queries++; return postCounts[blogId]; } // lazy load public Dictionary<int, int> BlogsWithPosts() { Queries++; return new(postCounts); } // Include} public static class Solution{ public static List<string> Run() { var lazy = new FakeDb(); var total = 0; foreach (var id in lazy.BlogIds()) total += lazy.PostsFor(id); var eager = new FakeDb(); var total2 = eager.BlogsWithPosts().Values.Sum(); return new List<string> { $"lazy: {total} posts in {lazy.Queries} queries", $"eager: {total2} posts in {eager.Queries} queries", }; }}The fix has its own trap
Include turns the loop into one query with a JOIN. Include two
sibling collections and the join multiplies: each blog row repeats
for every post times every tag, because the posts and tags have no
relationship to each other. This is cartesian explosion. When a
query loads more than one collection navigation and you have not
chosen a query splitting behaviour, EF Core logs a warning saying so.
EF Core still builds each blog, post and tag only once from the
repeated rows, so the cost is bandwidth and time rather than wrong
results.
db.Blogs.Include(b => b.Posts).Include(b => b.Tags).AsSplitQuery();
AsSplitQuery() loads each collection in its own query instead: more
round trips, far fewer rows. Split queries are not one atomic read, so
data changed between them can be inconsistent unless you run them in a
serializable or snapshot transaction. The arithmetic below counts the
rows each approach returns for a hundred blogs.
public static class Solution{ public static List<string> Run() { // 100 blogs, each with 20 posts and 10 tags. int blogs = 100, posts = 20, tags = 10; // LEFT JOIN Posts LEFT JOIN Tags: every post pairs with every tag of its blog. var singleRows = blogs * posts * tags; // One query per collection. var splitRows = blogs + blogs * posts + blogs * tags; return new List<string> { $"single query: 1 round trip, {singleRows} rows", $"split query: 3 round trips, {splitRows} rows", }; }}Often the best fix is neither: project only what the page needs.
var rows = await db.Blogs
.Select(b => new { b.Title, PostCount = b.Posts.Count })
.ToListAsync(); // one query, two columns
Common mistakes
- Enabling lazy loading globally and serialising entities to JSON. The serialiser walks every navigation and triggers a query for each one.
- Fixing N+1 by including every collection. Two sibling collection includes on a large parent set can return far more rows than the N+1 version did.
- Loading whole entities to display two fields. A projection is one narrow query, and its result is not tracked.
- Not looking at the SQL. Turn on
Microsoft.EntityFrameworkCore.Database.Commandlogging, orLogTo, and count the statements per request.
What interviewers ask
- What is the N+1 problem and how do you detect it? One query for a list plus one per item via lazy navigation; spot it in SQL logs or an APM trace showing many identical statements per request.
IncludeorAsSplitQuery? Single query for one collection or small sets; split for several collections where the join multiplies; mention the consistency trade-off.- When would you avoid loading entities at all? Read-only views:
project with
Selectto a DTO.
Exercise · Pro
Build blogs from joined rows
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade