EF Core N+1 query problem: causes, detection and fixes (projection, Include, AsSplitQuery)

EF Core Interview Q&A November 13, 2026 csharp

1 query for the list plus N queries, one per item, for the related data.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// ======================================================================
// Q: What is the N+1 problem in EF Core? How do you detect and fix it?
//
// 1 query for the list plus N queries, one per item, for the related data. With
// 500 orders: 501 round trips. It is invisible locally; in production with
// database latency it becomes seconds.
// - Projection is the best fix for reads: one query, only the columns you need.
// - Eager loading with Include when you need the entities (e.g. to modify them).
// - Batch loading with Contains when the data comes from elsewhere (e.g. ids
//   from the ERP).
// - AsSplitQuery with multiple collection Includes. A JOIN across two
//   collections returns lines × payments rows (cartesian explosion).
// - In production: Application Insights / OpenTelemetry. The telltale sign: the
//   same query repeated dozens of times in the same request.
// - Lazy loading is off by default in EF Core. Leave it off in web APIs. Also
//   watch serialization: entities with navigation properties in JSON can trigger
//   lazy loading. Return DTOs.
// ======================================================================

// ✗ Common causes of N+1
// ✗ Cause 1: lazy loading (UseLazyLoadingProxies). Every navigation = a new query
var orders = await db.Orders.Where(o => o.Status == OrderStatus.Placed).ToListAsync(ct);
foreach (var o in orders)
    Console.WriteLine($"{o.Customer.Name}: {o.Lines.Count} lines");   // 1 + 2N queries

// ✗ Cause 2: query inside a loop (even without lazy loading)
foreach (var o in orders)
{
    var customer = await db.Customers.FindAsync([o.CustomerId], ct); // N queries
}

// ✗ Cause 3: calling a service / repository per item
var dtos = new List<OrderDto>();
foreach (var o in orders)
    dtos.Add(await orderService.GetDetailsAsync(o.Id, ct));       // N queries, "hidden"

// Fix 1: projection (best for reads)
var rows = await db.Orders.AsNoTracking()
    .Where(o => o.Status == OrderStatus.Placed)
    .Select(o => new OrderRow(o.OrderNumber, o.Customer.Name, o.Lines.Count))
    .ToListAsync(ct);                                   // 1 query with JOIN / subquery

// Fix 2: eager loading with Include (when you need the entities)
var orders = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines).ThenInclude(l => l.Product)
    .Where(o => o.Status == OrderStatus.Placed)
    .ToListAsync(ct);

// Fix 3: batch loading with Contains
var ids = orders.Select(o => o.CustomerId).Distinct().ToList();
var customers = await db.Customers
    .Where(c => ids.Contains(c.Id))                 // WHERE id = ANY(@ids): 1 query
    .ToDictionaryAsync(c => c.Id, ct);

foreach (var o in orders)
    Console.WriteLine(customers[o.CustomerId].Name);

// Fix 4: AsSplitQuery for multiple collection Includes
var orders = await db.Orders
    .Include(o => o.Lines)
    .Include(o => o.Payments)
    .AsSplitQuery()                // 3 small queries instead of 1 huge JOIN
    .ToListAsync(ct);

// Detection
// 1. See the SQL in development
options.UseNpgsql(cs)
       .LogTo(Console.WriteLine, LogLevel.Information)
       .EnableSensitiveDataLogging(env.IsDevelopment())
       // warning → exception when AsSplitQuery is missing on multiple collection Includes
       .ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning));

// 2. A test that counts an endpoint's queries
public class QueryCounter : DbCommandInterceptor
{
    public int Count;
    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command, CommandEventData data,
        InterceptionResult<DbDataReader> result, CancellationToken ct = default)
    {
        Interlocked.Increment(ref Count);
        return base.ReaderExecutingAsync(command, data, result, ct);
    }
}
// Assert.True(counter.Count <= 2);   // if someone adds an N+1, the test breaks