Performance

EF Core Performance: 12 Fixes for Slow Queries

EF Core Performance: 12 Fixes for Slow Queries

When a client tells me their ASP.NET Core application is slow, the cause is usually not .NET itself. Nine times out of ten it's the data access layer: Entity Framework Core generating far more SQL than anyone realised, loading far more data than the page needs, or doing it hundreds of times per request. The good news is that most of these problems have small, safe fixes. Here are the twelve I apply most often, roughly in the order I check them.

Before you fix anything: measure

Never optimise blind. Turn on SQL logging in development so you can see exactly what EF Core sends to the database:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    if (builder.Environment.IsDevelopment())
    {
        options.LogTo(Console.WriteLine, LogLevel.Information)
               .EnableSensitiveDataLogging();
    }
});

In production, use OpenTelemetry (or Application Insights) to see query count and duration per request, and the database's own tools (Query Store on SQL Server, pg_stat_statements on PostgreSQL) to find the slowest statements. Fix the worst offender, measure again, repeat.

1. Kill N+1 queries

The classic: load a list, then touch a navigation property inside a loop, and EF Core (with lazy loading) runs one extra query per row.

// 1 query for orders + 1 query per order for its customer
foreach (var order in await db.Orders.ToListAsync())
    Console.WriteLine(order.Customer.Name);

Either load related data eagerly with Include, or (better) project only what you need, which EF Core turns into a single join:

var rows = await db.Orders
    .Select(o => new { o.Id, o.Total, CustomerName = o.Customer.Name })
    .ToListAsync();

If you use lazy loading proxies, consider turning them off. They make N+1 bugs invisible.

2. Project to DTOs instead of loading entities

Loading full entities for a read-only list page pulls every column, including large text fields, and then tracks them. A Select into a DTO fetches only the columns you use and skips change tracking entirely. This single change often cuts query time and memory by more than half.

3. Use AsNoTracking for read-only queries

When you do need entities but won't modify them, tell EF Core not to track them:

var products = await db.Products.AsNoTracking()
    .Where(p => p.IsActive)
    .ToListAsync();

For read-heavy services you can make it the default with options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) and opt in to tracking with AsTracking() where you update data.

4. Filter and page in the database, not in memory

Calling ToListAsync() (or AsEnumerable()) too early means every subsequent Where, OrderBy and Take runs in your web server's memory after the whole table has been downloaded. Keep the query as IQueryable until the last moment, and always page large lists.

5. Prefer keyset pagination for deep pages

Skip(10000).Take(20) forces the database to read and discard 10,000 rows. For feeds, exports and infinite scroll, page by the last key you saw instead:

var page = await db.Orders.AsNoTracking()
    .Where(o => o.CreatedAt < lastSeenCreatedAt
             || (o.CreatedAt == lastSeenCreatedAt && o.Id < lastSeenId))
    .OrderByDescending(o => o.CreatedAt).ThenByDescending(o => o.Id)
    .Take(20)
    .Select(o => new OrderListItem(o.Id, o.Number, o.Total, o.CreatedAt))
    .ToListAsync();

Combined with an index on (CreatedAt, Id), every page is equally fast.

6. Add the indexes your queries need

EF Core creates indexes for foreign keys, but not for the columns you filter and sort by. Look at the WHERE and ORDER BY clauses of your slow queries and declare matching indexes in your model so they're versioned with migrations:

modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerId, o.CreatedAt })
    .IncludeProperties(o => new { o.Total, o.Status });   // SQL Server covering index

Check the actual execution plan to confirm the index is used. Don't index everything: each index slows down writes.

7. Avoid cartesian explosion with split queries

Including two or more collection navigations in one query multiplies rows: 1 order × 50 lines × 20 events returns 1,000 rows over the wire. Use split queries for those cases:

var order = await db.Orders
    .Include(o => o.Lines)
    .Include(o => o.Events)
    .AsSplitQuery()
    .SingleAsync(o => o.Id == id);

Split queries run one query per collection. They're not always faster, so measure, but for wide graphs they usually are.

8. Use ExecuteUpdate and ExecuteDelete for bulk changes

Loading thousands of entities to change one column and then calling SaveChanges is slow and memory hungry. Since EF Core 7 you can update and delete directly in the database:

await db.Subscriptions
    .Where(s => s.ExpiresAt < DateTime.UtcNow && s.Status == Status.Active)
    .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, Status.Expired));

One SQL statement, no entities loaded. Remember it bypasses the change tracker, so in-memory entities and SaveChanges interceptors won't see the change.

9. Batch inserts sensibly

EF Core already batches multiple inserts into fewer round trips when you call SaveChanges once for many entities. The mistake is calling SaveChangesAsync() inside the loop. For very large imports (hundreds of thousands of rows), use SqlBulkCopy or a bulk extension library instead of the change tracker.

10. Pool DbContext instances in hot paths

For high-throughput APIs, AddDbContextPool reuses context instances instead of creating one per request, reducing allocations:

builder.Services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(connectionString));

Pooling requires that your context has no per-request state of its own. If you inject tenant or user information into the context, use a pooled factory with care or keep regular registration.

11. Compile your hottest queries

EF Core caches query plans, but translating a LINQ expression still costs something on every call. For a query executed thousands of times per second, a compiled query removes that overhead:

private static readonly Func<AppDbContext, Guid, Task<ProductDto?>> GetProduct =
    EF.CompileAsyncQuery((AppDbContext db, Guid id) =>
        db.Products.Where(p => p.Id == id)
                   .Select(p => new ProductDto(p.Id, p.Name, p.Price))
                   .FirstOrDefault());

This is a micro-optimisation. Do it last, and only for queries the profiler shows are hot.

12. Cache what doesn't change often

The fastest query is the one you don't run. Reference data, product catalogues, settings and permissions are perfect for caching. .NET 9 introduced HybridCache, which combines an in-memory cache with a distributed one (such as Redis) and protects against cache stampedes:

var categories = await cache.GetOrCreateAsync("catalog:categories",
    async ct => await db.Categories.AsNoTracking()
        .Select(c => new CategoryDto(c.Id, c.Name))
        .ToListAsync(ct),
    new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) });

Invalidate (or tag and evict) entries when the underlying data changes, so users never see stale data for longer than you've agreed.

A quick checklist

  • SQL logging on in development; query metrics in production.
  • No lazy loading surprises; no queries inside loops.
  • DTO projections for reads; AsNoTracking where entities are read-only.
  • Filtering, sorting and paging happen in SQL; keyset paging for deep lists.
  • Indexes match real query patterns.
  • AsSplitQuery for multiple collection includes.
  • ExecuteUpdate/ExecuteDelete for bulk changes; one SaveChanges per unit of work.
  • Pooling, compiled queries and caching for the hottest paths.

Summary

EF Core is fast when you let the database do the work: fetch only the columns and rows you need, avoid accidental round trips, and give the database the indexes it needs. Measure first, fix the biggest problem, and measure again. In my experience the first three fixes alone often turn a multi-second page into one that loads in a couple of hundred milliseconds.

If your .NET application is slow and you'd like a second pair of eyes, take a look at my performance optimisation service or get in touch.

Share

We use cookies to personalize content and ads to make our site easier for you to use. We also share that information with third parties for advertising and analytics. By using this site, you agree to our terms & conditions. Privacy Policy