Case 4: The Lazy Loading Trap (The Hidden N+1 Problem) β
The Scenario π β
- System: An ASP.NET Core application with EF Core. Lazy Loading has been enabled for convenience (using the
Microsoft.EntityFrameworkCore.Proxiespackage). - Problem: We need to display a list of 10 blog posts, and under each post, we need to show a list of its related
Tags.
The Problematic Code (It Looks Innocent) β
The code in a Razor Page or Controller looks clean and simple.
csharp
// 1. Get 10 posts
var posts = await _context.Posts.Take(10).ToListAsync();
// 2. Loop and display (this is where the problem happens)
foreach (var post in posts)
{
Console.WriteLine($"Post: {post.Title}");
// When post.Tags is accessed for the first time, Lazy Loading is triggered,
// running a hidden query to get the tags for THIS ONE POST.
foreach (var tag in post.Tags)
{
Console.WriteLine($" - Tag: {tag.Name}");
}
}The Hidden Bottleneck π§ β
What is Lazy Loading?: When this feature is on, EF Core creates special "proxy" versions of your objects. When you access a related property like
post.Tagsfor the first time and it hasn't been loaded yet, the proxy automatically sends a query to the database to get that data.The N+1 Problem:
- Query 1:
_context.Posts.Take(10).ToListAsync()- This sends 1 query to get 10 posts. - Queries 2 to 11 (The "N" queries): Inside the
foreachloop, when the code hitsforeach (var tag in post.Tags), EF Core secretly runs a new query for each post:SELECT * FROM "Tags" WHERE "PostId" = @p0. - Total: The system makes 11 separate trips to the database just to display this simple page.
- Query 1:
The Solution: Load Data Explicitly (Eager Loading) β β
The Logic: Instead of letting EF Core be "lazy" and fetch data on demand, we tell it to load all the data we need in the very first query.
The Optimized Code:
csharpvar posts = await _context.Posts .Include(p => p.Tags) // Use Include to Eager Load .AsNoTracking() // Still use AsNoTracking for read-only queries .Take(10) .ToListAsync(); // This loop is now completely safe; no hidden queries are run. foreach (var post in posts) { Console.WriteLine($"Post: {post.Title}"); foreach (var tag in post.Tags) // The data is already in memory { Console.WriteLine($" - Tag: {tag.Name}"); } }
The Results β¨ β
- A Single, Efficient Query: EF Core now generates one single, efficient query that uses a
LEFT JOINto get both the posts and their related tags in one go.sqlSELECT "p"."Id", "p"."Title", ..., "t"."Id", "t"."Name", ... FROM "Posts" AS "p" LEFT JOIN "Tags" AS "t" ON "p"."Id" = "t"."PostId" ... - Fewer Database Round-trips: The number of trips to the database is reduced from 11 to just 1. This is a massive performance improvement, especially when network latency is high.
Conclusion:
- Lazy Loading is convenient for rapid development or simple scenarios, but it's a double-edged sword and a common cause of hard-to-detect N+1 problems.
- Golden Rule: Always eager load the data you know you will need by using
.Include(). Be in control of what your queries are doing. - Many experienced teams even disable Lazy Loading entirely to prevent these accidental performance issues.