EF Core Performance

Apply the EF Core optimisations that pay off in a busy API: projections, no-tracking, context pooling, compiled queries and keyset pagination.

Syntaxbuilder.Services.AddDbContextPool<CatalogDb>(o => o.UseNpgsql(cs) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)); static readonly Func<CatalogDb, string, Task<ProductView?>> BySku = EF.CompileAsyncQuery(...);

Most slow EF Core endpoints are slow because of the query shape: N+1, cartesian explosion, loading whole entities to show three fields, OFFSET deep into a table, a missing index. EF's own overhead per query is small next to a database round trip. Fix shape first, then look at the techniques below, roughly in order of payoff.

TechniqueSavesWorth it when
Project with SelectColumns, bytes, materialisation, trackingEvery read endpoint
No-tracking queriesSnapshots and identity resolution per entityReads; make it the context default
Keyset paginationRows the database reads and throws awayLarge tables, feeds, exports
DbContext poolingAllocating and initialising a context per requestThousands of requests per second
Compiled queriesHashing the expression tree and the cache lookupA few very hot queries
Compiled model (dotnet ef dbcontext optimize)Model building at start-upHundreds of entity types, slow cold starts

Tracking

A tracked query stores a snapshot of every entity so SaveChanges can find changes. For reads that is wasted memory and CPU. UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) makes no-tracking the default; write paths opt back in with AsTracking(). AsNoTrackingWithIdentityResolution() still returns one instance per key when the same customer appears under many orders.

Query caching and compiled queries

EF translates each LINQ shape once and caches the result keyed by the expression tree. Captured variables become SQL parameters, so Where(o => o.Id == id) is one cache entry for every id. Code that builds expressions with Expression.Constant(value) creates a new shape per value: unbounded translations in EF and a polluted plan cache in the database. EF.CompileAsyncQuery skips even the cache lookup by translating once into a delegate; that saves microseconds, so keep it for a handful of hot queries.

Pooling

AddDbContextPool keeps up to 1024 context instances by default and resets each one when it is returned. The pool size limits retained instances, not concurrency, and has nothing to do with the database connection pool (Npgsql and SqlClient default to 100 connections each). The constraints: the context needs a constructor taking only DbContextOptions, no scoped services can be injected, and any field you add survives into the next request unless you reset it.

Keyset pagination

Skip(10000).Take(50) makes the database read and discard 10,000 rows, and pages shift when rows are inserted. Keyset pagination remembers the last row's sort key and asks for rows after it: Where(o => o.PlacedAt < cursor.PlacedAt || (o.PlacedAt == cursor.PlacedAt && o.Id < cursor.Id)) with an index on (PlacedAt DESC, Id DESC). Each page costs the same; the trade-off is no jumping to page 200.

Measure

Log commands slower than a threshold, read EXPLAIN ANALYZE or the SQL Server actual plan for the worst ones, and benchmark changes with BenchmarkDotNet against the real engine, not the in-memory provider.

Example

Example · csharp
using System.Buffers.Text;
using System.Text;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContextPool<CatalogDb>(o => o
    .UseNpgsql(builder.Configuration.GetConnectionString("Catalog"))
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

var app = builder.Build();

// Hot path: called on every product page view.
app.MapGet("/products/{sku}", async Task<Results<Ok<ProductView>, NotFound>> (string sku, CatalogDb db, CancellationToken ct) =>
    await CatalogQueries.BySku(db, sku, ct) is { } product ? TypedResults.Ok(product) : TypedResults.NotFound());

// Keyset pagination: newest orders first, constant cost per page.
app.MapGet("/orders", async (string? after, CatalogDb db, CancellationToken ct) =>
{
    const int pageSize = 50;
    var query = db.Orders.AsQueryable();

    if (OrderCursor.TryDecode(after, out var cursor))
    {
        query = query.Where(o => o.PlacedAt < cursor.PlacedAt
            || (o.PlacedAt == cursor.PlacedAt && o.Id < cursor.Id));
    }

    var page = await query
        .OrderByDescending(o => o.PlacedAt).ThenByDescending(o => o.Id)
        .Take(pageSize + 1)
        .Select(o => new OrderListItem(o.Id, o.CustomerId, o.PlacedAt, o.Total))
        .ToListAsync(ct);

    var hasMore = page.Count > pageSize;
    if (hasMore) page.RemoveAt(pageSize);
    var next = hasMore ? OrderCursor.Encode(page[^1].PlacedAt, page[^1].Id) : null;
    return TypedResults.Ok(new OrderPage(page, next));
});

app.Run();

public record ProductView(int Id, string Sku, string Name, decimal Price, int Stock);
public record OrderListItem(long Id, int CustomerId, DateTimeOffset PlacedAt, decimal Total);
public record OrderPage(List<OrderListItem> Items, string? Next);

public static class CatalogQueries
{
    public static readonly Func<CatalogDb, string, CancellationToken, Task<ProductView?>> BySku =
        EF.CompileAsyncQuery((CatalogDb db, string sku, CancellationToken ct) =>
            db.Products
                .Where(p => p.Sku == sku)
                .Select(p => new ProductView(p.Id, p.Sku, p.Name, p.Price, p.Stock))
                .FirstOrDefault());
}

public readonly record struct OrderCursor(DateTimeOffset PlacedAt, long Id)
{
    public static string Encode(DateTimeOffset placedAt, long id) =>
        Base64Url.EncodeToString(Encoding.UTF8.GetBytes($"{placedAt.UtcTicks}:{id}"));

    public static bool TryDecode(string? value, out OrderCursor cursor)
    {
        cursor = default;
        if (string.IsNullOrEmpty(value)) return false;
        try
        {
            var parts = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(value)).Split(':');
            if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !long.TryParse(parts[1], out var id)) return false;
            cursor = new OrderCursor(new DateTimeOffset(ticks, TimeSpan.Zero), id);
            return true;
        }
        catch (FormatException)
        {
            return false;
        }
    }
}

public class Product
{
    public int Id { get; set; }
    public string Sku { get; set; } = "";
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

public class Order
{
    public long Id { get; set; }
    public int CustomerId { get; set; }
    public DateTimeOffset PlacedAt { get; set; }
    public decimal Total { get; set; }
}

public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().HasIndex(p => p.Sku).IsUnique();
        // Matches the ORDER BY of the keyset query exactly.
        modelBuilder.Entity<Order>().HasIndex(o => new { o.PlacedAt, o.Id }).IsDescending(true, true);
    }
}

When to use it

  • Serve an infinite-scroll order feed over 30 million rows with keyset pagination so page 500 costs the same as page 1.
  • Cut allocations on the product lookup endpoint, called 3,000 times a second, with a pooled no-tracking context and a compiled query.
  • Bring cold-start time of a 400-entity model down for faster autoscaling by generating a compiled model in CI.

More examples

Pooling with per-request state through a scoped factory

A pooled context is constructed once from the root provider, so it cannot take <code>ITenantContext</code> in its constructor. The pooled factory is a singleton that leases instances; the scoped wrapper sets the tenant on every lease, so a recycled instance never carries the previous request's tenant. This is the documented pattern for pooling with multi-tenancy. Only adopt pooling after measuring: below a few thousand requests per second the gain is hard to see.

Example · csharp
using Microsoft.EntityFrameworkCore;

public interface ITenantContext
{
    Guid TenantId { get; }
}

public class Order
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
    public decimal Total { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    // Set on every lease by ScopedShopDbFactory; never left over from a previous request.
    public Guid TenantId { get; set; }

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Order>().HasQueryFilter("Tenant", o => o.TenantId == TenantId);
}

public sealed class ScopedShopDbFactory(IDbContextFactory<ShopDb> pooledFactory, ITenantContext tenant)
    : IDbContextFactory<ShopDb>
{
    public ShopDb CreateDbContext()
    {
        var db = pooledFactory.CreateDbContext();
        db.TenantId = tenant.TenantId;
        return db;
    }
}

public static class ShopDbRegistration
{
    public static IServiceCollection AddPooledShopDb(this IServiceCollection services, string connectionString)
    {
        services.AddPooledDbContextFactory<ShopDb>(o => o.UseNpgsql(connectionString), poolSize: 512);
        services.AddScoped<ScopedShopDbFactory>();
        // Handlers keep injecting ShopDb; the scope disposes it, which returns it to the pool.
        services.AddScoped(sp => sp.GetRequiredService<ScopedShopDbFactory>().CreateDbContext());
        return services;
    }
}

Benchmarking tracking, no-tracking and projection

Run it with <code>dotnet run -c Release</code>. <code>[MemoryDiagnoser]</code> reports allocations per operation, which is where tracking and wide entities show up most clearly: the projection skips the 400-character notes column and creates no snapshots. SQLite in memory isolates EF's own cost from network latency; to judge the effect of an index or a pagination strategy, point the same benchmark at a PostgreSQL container instead.

Example · csharp
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

BenchmarkRunner.Run<OrderReadBenchmarks>();

public record OrderListItem(int Id, decimal Total);

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public decimal Total { get; set; }
    public string ShippingAddress { get; set; } = "";
    public string Notes { get; set; } = "";
}

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

[MemoryDiagnoser]
public class OrderReadBenchmarks
{
    private SqliteConnection _connection = null!;
    private DbContextOptions<ShopDb> _options = null!;

    [GlobalSetup]
    public void Setup()
    {
        _connection = new SqliteConnection("Data Source=:memory:");
        _connection.Open();
        _options = new DbContextOptionsBuilder<ShopDb>().UseSqlite(_connection).Options;
        using var db = new ShopDb(_options);
        db.Database.EnsureCreated();
        db.Orders.AddRange(Enumerable.Range(1, 1_000).Select(i => new Order
        {
            CustomerId = i % 50, Total = i * 1.5m, ShippingAddress = "1 Harbour Street, Leith", Notes = new string('x', 400)
        }));
        db.SaveChanges();
    }

    [GlobalCleanup]
    public void Cleanup() => _connection.Dispose();

    [Benchmark(Baseline = true)]
    public int Tracking() { using var db = new ShopDb(_options); return db.Orders.ToList().Count; }

    [Benchmark]
    public int NoTracking() { using var db = new ShopDb(_options); return db.Orders.AsNoTracking().ToList().Count; }

    [Benchmark]
    public int Projection()
    {
        using var db = new ShopDb(_options);
        return db.Orders.Select(o => new OrderListItem(o.Id, o.Total)).ToList().Count;
    }
}

A compiled model for faster cold starts

Building the model means reflecting over every entity and running every convention, which for a few hundred entity types can take seconds at the first query of each process: slow cold starts and slow scale-out. A compiled model is generated C# that the context loads instead. The cost is a build step: a stale compiled model does not match the code, so regenerate it in CI whenever the model changes, and skip it entirely for small models.

Example · bash
# Generate a compiled model into the data project (re-run after every model change).
dotnet ef dbcontext optimize \
  --project src/Shop.Data \
  --startup-project src/Shop.Api \
  --context CatalogDb \
  --output-dir CompiledModels \
  --namespace Shop.Data.CompiledModels

# The command generates CatalogDbModel; use it in the context options:
#   options.UseNpgsql(cs).UseModel(Shop.Data.CompiledModels.CatalogDbModel.Instance);

# Measure before and after: time to first query on a cold container start.
dotnet publish src/Shop.Api -c Release -o out

Discussion

  • Be the first to comment on this lesson.