N+1 Queries and Split Queries

Find and remove N+1 query patterns, choose between projection, Include and batching, and stop cartesian explosion with AsSplitQuery.

Syntaxdb.Orders.Include(o => o.Lines).Include(o => o.Payments).AsSplitQuery() db.Orders.Select(o => new OrderDto(o.Id, o.Lines.Count))

An N+1 problem is one query for a list followed by one more query per row of that list. Twenty orders become 21 round trips; a page of 500 rows with 1 ms of network latency to the database spends half a second waiting before any SQL work is done. It hides in development, where the database is local and tables hold ten rows, and grows linearly with data in production.

Where it comes from in EF Core

  • A query inside a loop. The most common cause: foreach (var order in orders) calling db.Payments.Where(p => p.OrderId == order.Id).ToListAsync(), often buried in a mapper or helper that looks harmless.
  • Explicit loading in a loop. db.Entry(order).Collection(o => o.Lines).LoadAsync() once per row.
  • Lazy loading. Off by default. It needs the Microsoft.EntityFrameworkCore.Proxies package, UseLazyLoadingProxies() and virtual navigations. Once on, every navigation access runs a synchronous query, including the ones the JSON serializer makes while writing the response. Most API teams leave it off, so a missing Include shows up as an empty collection in a test rather than a query storm in production.

The fixes, in order of preference

Project to a DTO. Select(o => new OrderSummary(o.Id, o.Lines.Select(...).ToList())) is translated into one SQL statement with a JOIN, reads only the columns the response needs, and is never tracked. Include loads whole entities; use it when you are going to modify them. Batch by key when no navigation reaches the data: collect the ids, run one Where(p => ids.Contains(p.OrderId)) query and group in memory.

Cartesian explosion and split queries

EF Core loads collections with JOINs in one statement by default. One collection is fine; two sibling collections multiply. An order with 50 lines and 20 payments returns 1,000 rows, each repeating every order column. EF logs MultipleCollectionIncludeWarning when it compiles such a query without a configured splitting behaviour. AsSplitQuery() sends one SELECT for the orders and one per included collection, each joined back on the key.

Single query (default)Split query
Round trips11 plus one per collection
Rows transferredProduct of the collection sizesSum of the collection sizes
ConsistencyOne statement, one snapshotSeparate statements; a write can land between them unless a snapshot or serializable transaction wraps them
Skip/TakeSafeNeeds a unique, deterministic OrderBy or the batches disagree

Split per query with AsSplitQuery(), or set UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) on the provider options and opt back in with AsSingleQuery(). Most teams keep the single-query default and split only the endpoints that load two or more collections.

Spotting it

Set the Microsoft.EntityFrameworkCore.Database.Command log category to Information in Development and look for the same SELECT repeated with different parameter values. In production a trace shows dozens of identical database spans under one request. Lock the fix in with a test that counts the commands an operation sends.

Example

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShopDb>(options =>
{
    options.UseNpgsql(builder.Configuration.GetConnectionString("Shop"));
    if (builder.Environment.IsDevelopment())
    {
        // Fail fast in development instead of shipping a lines x payments row explosion.
        options.ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning));
    }
});

var app = builder.Build();

// One SQL statement: the nested collection in the projection becomes a LEFT JOIN.
app.MapGet("/customers/{customerId:int}/orders", async (int customerId, ShopDb db, CancellationToken ct) =>
{
    var orders = await db.Orders
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.PlacedAt)
        .ThenByDescending(o => o.Id)
        .Take(20)
        .Select(o => new OrderSummary(
            o.Id,
            o.PlacedAt,
            o.Lines.Sum(l => l.Quantity * l.UnitPrice),
            o.Lines.Select(l => new LineSummary(l.Sku, l.Quantity)).ToList()))
        .ToListAsync(ct);

    return TypedResults.Ok(orders);
});

// Two collections: one query for the order, one for its lines, one for its payments.
app.MapGet("/orders/{id:int}", async Task<Results<Ok<OrderDetail>, NotFound>> (int id, ShopDb db, CancellationToken ct) =>
{
    var order = await db.Orders
        .AsNoTracking()
        .Include(o => o.Lines)
        .Include(o => o.Payments)
        .AsSplitQuery()
        .SingleOrDefaultAsync(o => o.Id == id, ct);

    if (order is null) return TypedResults.NotFound();

    return TypedResults.Ok(new OrderDetail(
        order.Id,
        order.Lines.Select(l => new LineSummary(l.Sku, l.Quantity)).ToList(),
        order.Payments.Sum(p => p.Amount)));
});

app.Run();

public record LineSummary(string Sku, int Quantity);
public record OrderSummary(int Id, DateTimeOffset PlacedAt, decimal Total, List<LineSummary> Lines);
public record OrderDetail(int Id, List<LineSummary> Lines, decimal Paid);

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public DateTimeOffset PlacedAt { get; set; }
    public List<OrderLine> Lines { get; set; } = [];
    public List<Payment> Payments { get; set; } = [];
}

public class OrderLine
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public string Sku { get; set; } = "";
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public class Payment
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public decimal Amount { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

When to use it

  • Replace a per-order payment lookup in an invoice export with one grouped query so a 2,000-row export drops from 2,001 round trips to two.
  • Add AsSplitQuery to GET /orders/{id} that includes both lines and payments, after the trace shows a 4,000-row result for one order.
  • Fail the build in development when a new query loads two collections in one statement, by throwing on MultipleCollectionIncludeWarning.

More examples

The query in a loop, and the batched fix

The slow version reads correctly and passes every functional test, which is why N+1 survives code review. The fast version sends the ids as parameters (Npgsql passes one array parameter, <code>= ANY(@ids)</code>; SQL Server and SQLite expand the list into an <code>IN</code> list of scalar parameters) and aggregates in SQL. With a navigation from <code>Invoice</code> to its payments, a single projection with <code>i.Payments.Sum(p => p.Amount)</code> would do it in one statement; batching by key is for data no navigation reaches. Keep the id list bounded: tens of thousands of ids belong in a join against a table, not a parameter.

Example · csharp
using Microsoft.EntityFrameworkCore;

public record InvoiceRow(int InvoiceId, string CustomerName, decimal Amount, decimal Paid);

public class Invoice
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public string CustomerName { get; set; } = "";
    public decimal Amount { get; set; }
    public DateOnly DueOn { get; set; }
}

public class InvoicePayment
{
    public int Id { get; set; }
    public int InvoiceId { get; set; }
    public decimal Amount { get; set; }
}

public class BillingDb(DbContextOptions<BillingDb> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<InvoicePayment> Payments => Set<InvoicePayment>();
}

public class OverdueReport(BillingDb db)
{
    // N+1: one query for the invoices, then one SUM per invoice.
    public async Task<List<InvoiceRow>> SlowAsync(DateOnly today, CancellationToken ct)
    {
        var invoices = await db.Invoices.AsNoTracking().Where(i => i.DueOn < today).ToListAsync(ct);
        var rows = new List<InvoiceRow>();
        foreach (var invoice in invoices)
        {
            var paid = await db.Payments.Where(p => p.InvoiceId == invoice.Id).SumAsync(p => p.Amount, ct);
            rows.Add(new InvoiceRow(invoice.Id, invoice.CustomerName, invoice.Amount, paid));
        }
        return rows;
    }

    // Two queries in total, whatever the number of invoices.
    public async Task<List<InvoiceRow>> FastAsync(DateOnly today, CancellationToken ct)
    {
        var invoices = await db.Invoices.AsNoTracking().Where(i => i.DueOn < today).ToListAsync(ct);
        var ids = invoices.Select(i => i.Id).ToList();

        var paidById = await db.Payments
            .Where(p => ids.Contains(p.InvoiceId))
            .GroupBy(p => p.InvoiceId)
            .Select(g => new { InvoiceId = g.Key, Paid = g.Sum(p => p.Amount) })
            .ToDictionaryAsync(x => x.InvoiceId, x => x.Paid, ct);

        return invoices
            .Select(i => new InvoiceRow(i.Id, i.CustomerName, i.Amount, paidById.GetValueOrDefault(i.Id)))
            .ToList();
    }
}

A test that fails when the query count grows

The interceptor counts every reader the context opens, so the assertion pins the operation to exactly one SQL statement. Someone who later adds a per-row lookup gets a red test instead of a slow endpoint. Both <code>ReaderExecuting</code> and <code>ReaderExecutingAsync</code> are overridden because EF calls only the one matching the sync or async API in use. The counter is read before and after the call so the inserts from seeding, which also use readers to fetch generated keys, do not count.

Example · csharp
using System.Data.Common;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Xunit;

public sealed class CommandCounter : DbCommandInterceptor
{
    private int _count;
    public int Count => Volatile.Read(ref _count);

    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        Interlocked.Increment(ref _count);
        return result;
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        Interlocked.Increment(ref _count);
        return ValueTask.FromResult(result);
    }
}

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public List<OrderLine> Lines { get; set; } = [];
}

public class OrderLine
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public string Sku { get; set; } = "";
    public int Quantity { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

public record OrderHistoryItem(int Id, int LineCount, int Units);

public static class OrderQueries
{
    public static Task<List<OrderHistoryItem>> HistoryAsync(ShopDb db, int customerId, CancellationToken ct) =>
        db.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderBy(o => o.Id)
            .Select(o => new OrderHistoryItem(o.Id, o.Lines.Count, o.Lines.Sum(l => l.Quantity)))
            .ToListAsync(ct);
}

public class OrderQueryTests
{
    [Fact]
    public async Task Order_history_is_one_query_regardless_of_order_count()
    {
        var ct = TestContext.Current.CancellationToken;
        await using var connection = new SqliteConnection("Data Source=:memory:");
        await connection.OpenAsync(ct);

        var counter = new CommandCounter();
        var options = new DbContextOptionsBuilder<ShopDb>().UseSqlite(connection).AddInterceptors(counter).Options;
        await using var db = new ShopDb(options);
        await db.Database.EnsureCreatedAsync(ct);

        for (var i = 0; i < 25; i++)
        {
            db.Orders.Add(new Order { CustomerId = 7, Lines = [new() { Sku = "KETTLE-BLUE", Quantity = 2 }] });
        }
        await db.SaveChangesAsync(ct);
        db.ChangeTracker.Clear();

        var before = counter.Count;
        var history = await OrderQueries.HistoryAsync(db, customerId: 7, ct);

        Assert.Equal(25, history.Count);
        Assert.Equal(1, counter.Count - before);
    }
}

Seeing the SQL in development

<code>AddDbContext</code> wires EF Core to the host's logging, so no code change is needed: the <code>Database.Command</code> category logs each command at Information with its duration and parameter names. Keep it out of <code>appsettings.json</code> for production, where it multiplies log volume by the number of queries per request. To see parameter values too, <code>EnableSensitiveDataLogging()</code> is needed, which is a development-only switch.

Example · json
{
  // appsettings.Development.json
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning",
      // Every executed command with its duration: repeated SELECTs are the N+1 signature.
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Discussion

  • Be the first to comment on this lesson.