The N+1 Query Problem

One of the most common performance killers in Entity Framework applications

Bad: N+1 Problem

18 ms

51 Queries Executed!
// ❌ BAD - Creates N+1 queries
var products = await _context.Products
    .Where(p => p.IsActive)
    .Take(50)
    .ToListAsync(); // 1st query

// Then for EACH product...
foreach (var product in products)
{
    // Additional query per product!
    var category = await _context.Categories
        .FirstAsync(c => c.Id == product.CategoryId);
    
    // 50 more queries = 51 total!
}
Why This is Bad:
  • 1 initial query + N additional queries (1 per record)
  • 51 round trips to the database
  • Huge performance overhead
  • Doesn't scale with data size

Good: Eager Loading

4 ms

1 Query Executed!
// ✅ GOOD - Single query with JOIN
var products = await _context.Products
    .Include(p => p.Category) // Eager load
    .Where(p => p.IsActive)
    .Take(50)
    .ToListAsync(); // Single query with JOIN

// Category data already loaded!
foreach (var product in products)
{
    // No additional query needed
    var categoryName = product.Category.Name;
}
Why This is Better:
  • Single query with SQL JOIN
  • One round trip to database
  • Excellent performance
  • Scales well with data size

Performance Improvement

77.8% Faster

By using Include() instead of lazy loading

What is the N+1 Problem?

The Problem

The N+1 problem occurs when you fetch a list of N entities, then for each entity, you make another query to fetch related data. This results in:

  • 1 query to fetch the main entities
  • N queries to fetch related data for each entity
  • Total: N+1 queries
Example: If you fetch 50 products, you end up with 51 queries: 1 for products + 50 for their categories = 51 total queries!
The Solution

Use eager loading with .Include() to fetch related data in a single query using SQL JOINs.

Available Loading Strategies:
Strategy When to Use
.Include() Always need related data
.ThenInclude() Multi-level relationships
.Select() Need specific properties only
Explicit Loading Conditionally load later
Single Level Include
// Load product with category
var products = await _context.Products
    .Include(p => p.Category)
    .Where(p => p.IsActive)
    .ToListAsync();
Multiple Includes
// Load multiple related entities
var products = await _context.Products
    .Include(p => p.Category)
    .Include(p => p.Reviews)
    .Include(p => p.Supplier)
    .ToListAsync();
Nested ThenInclude
// Load nested relationships
var products = await _context.Products
    .Include(p => p.Category)
        .ThenInclude(c => c.Department)
    .Include(p => p.Reviews)
        .ThenInclude(r => r.User)
    .ToListAsync();
Projection Alternative
// Use Select for specific data
var products = await _context.Products
    .Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        CategoryName = p.Category.Name
    })
    .ToListAsync();

How to Detect N+1 Problems

1. Enable SQL Logging
// In Program.cs
options.LogTo(
    Console.WriteLine,
    LogLevel.Information);

Watch for multiple similar queries

2. Use MiniProfiler
// Install package
dotnet add package MiniProfiler.AspNetCore.Mvc

// Shows all SQL queries
// in development UI

Visual query profiling tool

3. SQL Server Profiler
-- Monitor queries in SQL Server
-- Look for patterns of:
SELECT * FROM Products
SELECT * FROM Categories WHERE Id = 1
SELECT * FROM Categories WHERE Id = 2
SELECT * FROM Categories WHERE Id = 3

Database-level monitoring

Real-World Scenario

Scenario: E-commerce Product Listing Page
❌ Bad Implementation
public async Task<IActionResult> ProductList()
{
    // Get products
    var products = await _context.Products
        .Where(p => p.IsActive)
        .Take(20)
        .ToListAsync(); // 1 query
    
    // Build view models
    var viewModels = new List<ProductViewModel>();
    foreach (var product in products)
    {
        // Query 2-21: Get category
        var category = await _context.Categories
            .FindAsync(product.CategoryId);
        
        // Query 22-41: Get reviews
        var reviews = await _context.Reviews
            .Where(r => r.ProductId == product.Id)
            .ToListAsync();
        
        viewModels.Add(new ProductViewModel
        {
            Product = product,
            CategoryName = category.Name,
            ReviewCount = reviews.Count
        });
    }
    
    return View(viewModels);
}

// Result: 41 queries for 20 products!

Performance: 500-1000ms+ on production

✅ Good Implementation
public async Task<IActionResult> ProductList()
{
    // Single query with JOINs
    var viewModels = await _context.Products
        .Include(p => p.Category)
        .Include(p => p.Reviews)
        .Where(p => p.IsActive)
        .Select(p => new ProductViewModel
        {
            Product = p,
            CategoryName = p.Category.Name,
            ReviewCount = p.Reviews.Count
        })
        .Take(20)
        .AsNoTracking()
        .ToListAsync(); // 1 query total!
    
    return View(viewModels);
}

// Result: 1 optimized query

Performance: 20-50ms on production

Best Practices to Avoid N+1

✅ DO These Things
  • Use .Include() for related data you always need
  • Use .Select() to project exactly what you need
  • Enable SQL logging in development to catch issues early
  • Profile your queries with tools like MiniProfiler
  • Use AsSplitQuery() for multiple collections to avoid cartesian explosion
  • Test with realistic data volumes in staging
❌ DON'T Do These Things
  • Don't access navigation properties without Include()
  • Don't query inside loops - always load data upfront
  • Don't rely on lazy loading - it causes N+1 problems
  • Don't ignore query counts in your monitoring tools
  • Don't assume EF Core is magic - understand what queries run
  • Don't skip performance testing until production
Learn More

The N+1 problem is one of the most common performance issues in ORM-based applications. Understanding and avoiding it can lead to 10-100x performance improvements in real-world scenarios. Always profile your queries in development and use tools like MiniProfiler, Entity Framework logging, or SQL Server Profiler to catch these issues before they reach production.