Articles › EF Core & SQL

EF Core Performance: 12 Techniques for Faster Queries

Make Entity Framework Core fast: AsNoTracking, projections, avoiding N+1, split queries, bulk ExecuteUpdate, compiled queries, pooling, pagination and how to measure with logging.

Entity Framework Core is fast — when used well. Most "EF Core is slow" complaints come from a handful of patterns: loading too much data, tracking entities that are never modified, and issuing hundreds of queries where one would do. These twelve techniques fix the vast majority of real-world performance problems.

0. First, measure

Never optimize blind. Log the SQL EF Core generates and how long it takes:

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

You can also inspect a single query with query.ToQueryString(). Then look at the execution plan in SQL Server for anything slow.

1. Use AsNoTracking for read-only queries

var articles = await db.Articles
    .AsNoTracking()
    .Where(a => a.IsPublished)
    .ToListAsync();

Tracking stores a snapshot of every entity for change detection. For data you only display, it's pure overhead — often 20–40% slower and much more memory. For read-heavy contexts you can make it the default:

options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

2. Project only what you need

// ❌ Loads every column, including large Content fields
var list = await db.Articles.ToListAsync();

// ✅ Selects only the needed columns
var list = await db.Articles
    .Select(a => new ArticleSummary(a.Id, a.Slug, a.Title, a.PublishedAt))
    .ToListAsync();

Projections are automatically untracked, transfer less data and let SQL Server use covering indexes. This single change often gives the biggest win.

3. Kill the N+1 problem

// ❌ 1 query for blogs + 1 query per blog (N+1)
var blogs = await db.Blogs.ToListAsync();
foreach (var blog in blogs)
{
    var count = await db.Posts.CountAsync(p => p.BlogId == blog.Id);
}

// ✅ One query
var blogs = await db.Blogs
    .Select(b => new { b.Title, PostCount = b.Posts.Count })
    .ToListAsync();

N+1 also hides behind lazy loading. Disable it in APIs and load related data explicitly with Include or projections.

4. Use split queries for multiple collections

var orders = await db.Orders
    .Include(o => o.Items)
    .Include(o => o.Payments)
    .AsSplitQuery()
    .ToListAsync();

Including two collections in one query multiplies rows (items × payments) — the "cartesian explosion". AsSplitQuery() loads each collection with its own query. Measure: for small collections a single query can still be faster.

5. Filter and paginate in the database

// ❌ ToList() first, then filter in memory
var page = db.Products.ToList().Where(p => p.Price > 100).Skip(20).Take(20);

// ✅ Everything translated to SQL
var page = await db.Products
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Id)
    .Skip(20).Take(20)
    .ToListAsync();

Keep queries as IQueryable until they're fully composed. For deep pagination over large tables, use keyset pagination:

var next = await db.Products
    .Where(p => p.Id > lastSeenId)
    .OrderBy(p => p.Id)
    .Take(20)
    .ToListAsync();

6. Bulk updates and deletes without loading

// ✅ One UPDATE statement, no entities loaded
await db.Articles
    .Where(a => a.PublishedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(a => a.IsArchived, true)
        .SetProperty(a => a.UpdatedAt, DateTime.UtcNow));

await db.Sessions.Where(s => s.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync();

ExecuteUpdate/ExecuteDelete (EF Core 7+) bypass change tracking — be aware that tracked entities in the same context won't reflect the change.

7. Batch inserts sensibly

EF Core batches multiple inserts into one round trip automatically. Avoid calling SaveChanges inside loops:

// ❌ 1,000 round trips
foreach (var item in items) { db.Add(item); await db.SaveChangesAsync(); }

// ✅ Batched
db.AddRange(items);
await db.SaveChangesAsync();

For hundreds of thousands of rows, use SqlBulkCopy or a bulk extension library.

8. Index what you query

EF Core can't make a missing index fast. Add indexes for frequently filtered and sorted columns:

modelBuilder.Entity<Article>()
    .HasIndex(a => new { a.IsPublished, a.PublishedAt });

modelBuilder.Entity<Article>()
    .HasIndex(a => a.Slug).IsUnique();

Also avoid non-translatable or non-SARGable predicates (a.Title.ToLower().Contains(x) on a large table forces a scan; use proper collations or full-text search).

9. Compiled queries for hot paths

For queries executed thousands of times per second, skip the expression-tree translation cost:

private static readonly Func<AppDbContext, string, Task<Article?>> GetBySlug =
    EF.CompileAsyncQuery((AppDbContext db, string slug) =>
        db.Articles.AsNoTracking().FirstOrDefault(a => a.Slug == slug));

var article = await GetBySlug(db, slug);

EF Core already caches query plans, so only use this for proven hot spots.

10. DbContext pooling

builder.Services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(connectionString), poolSize: 128);

Pooling reuses context instances, reducing allocation and setup cost for high-throughput APIs. Don't store per-request state in fields of a pooled context.

11. Use async and cancellation everywhere

var items = await db.Products.Where(...).ToListAsync(cancellationToken);

Async I/O frees threads while SQL Server works, and cancellation stops work for requests the client already abandoned.

12. Load large data in streams

await foreach (var row in db.AuditLogs.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
    await writer.WriteLineAsync(row.ToCsv());
}

Streaming avoids loading millions of rows into memory at once — perfect for exports.

When to drop down to SQL

EF Core plays nicely with raw SQL when LINQ isn't the best tool:

var stats = await db.Database
    .SqlQuery<DailyStat>($"SELECT CAST(CreatedAt AS date) AS Day, COUNT(*) AS Count FROM Orders GROUP BY CAST(CreatedAt AS date)")
    .ToListAsync();

Complex reports, window functions and heavily tuned queries are fine in SQL (or Dapper) — the goal is fast, maintainable code, not LINQ purity.

Quick reference

Symptom Likely fix
Slow lists, high memory AsNoTracking, projection
Hundreds of similar queries in logs fix N+1 with projection/Include
Huge result sets with duplicates AsSplitQuery
Slow updates of many rows ExecuteUpdate / ExecuteDelete
Index scans in the plan add or fix indexes, SARGable predicates
High CPU on very frequent queries compiled queries, context pooling

Summary

Measure first, then apply the high-impact basics: no tracking for reads, projections, no N+1, filtering and paging in SQL, bulk operations without loading, and proper indexes. Reserve compiled queries, pooling and raw SQL for proven hot paths. With these habits, EF Core comfortably powers high-traffic production systems.

FAQ

Is Dapper always faster than EF Core?

For simple reads the difference is small when EF Core uses AsNoTracking and projections. Dapper shines for hand-tuned SQL; many projects use both.

Does Include always cause performance problems?

No. Including a single reference or small collection is fine. Problems start with many nested collections — then use projections or split queries.

Should I disable change tracking globally?

In read-heavy services, setting NoTracking as default and using AsTracking() for updates is a clean approach.