EF Core Performance Optimization: Essential Strategies to Boost Your App's Speed

EF Core Performance: 18 Proven Ways to Speed Up Queries

Explore proven EF Core performance tips to enhance your application efficiency. Start optimizing today for faster results!

.NET Development Databases·By amarozka · October 11, 2025

EF Core Performance: 18 Proven Ways to Speed Up Queries

Are you sure your EF Core queries are fast? I bet at least one of them is silently wasting milliseconds on every request. Multiply that by traffic and you get angry users and hot CPUs.

Goal of this guide: give you a practical checklist (with code) to cut query time, shrink allocations, and reduce database load today.

Quick wins first

Before deep tuning, grab these low‑risk changes. They are safe, fast to apply, and bring clear benefits.

  • Prefer projections over entities. Load only what you use.
  • Use AsNoTracking() for read‑only queries. Skip the change tracker when you don’t update the data.
  • Batch writes. Group inserts/updates into fewer roundtrips.
  • Disable lazy loading. It often causes N+1 queries.
  • AddDbContext pooling. Reuse context instances to cut allocations.
  • Measure everything. Enable query logging and count roundtrips.

We’ll go step by step with code.

1) Measure: logging, tags, counters

You cannot fix what you don’t measure.

// Program.cs
builder.Services.AddDbContextPool<AppDbContext>(opt =>
{
    opt.UseSqlServer(builder.Configuration.GetConnectionString("Sql"))
       .EnableSensitiveDataLogging(false)
       .EnableDetailedErrors();
});

Turn on compact logs and tag queries you audit:

// Example query with a tag
var topPosts = await ctx.Posts
    .TagWith("Home/TopPosts")
    .Where(p => p.Published)
    .OrderByDescending(p => p.Score)
    .Take(10)
    .Select(p => new { p.Id, p.Title, p.Score })
    .ToListAsync();

Add simple counters to spot regressions:

public sealed class QueryCounterInterceptor : DbCommandInterceptor
{
    public int Executed { get; private set; }
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        Executed++;
        return base.ReaderExecuting(command, eventData, result);
    }
}

Register it and expose a metric (Prometheus, AppMetrics, or just logs). If a page requires 12 SQL commands, you have a hint.

Tip: Log execution time percentiles (P50/P95). A low average hides spikes.

2) Project only what you need (select columns)

Don’t hydrate full entities when you show a list or an API response.

// Bad: Loads full entity + navigation graph
var posts = await ctx.Posts.Where(p => p.Published).ToListAsync();

// Better: Projects only the columns you really use
var postsVm = await ctx.Posts
    .Where(p => p.Published)
    .OrderByDescending(p => p.Score)
    .Select(p => new PostListItem
    {
        Id = p.Id,
        Title = p.Title,
        Score = p.Score,
        Author = new() { Id = p.Author.Id, Name = p.Author.Name }
    })
    .ToListAsync();

Projection reduces payload, skips change tracking for navigation graphs, and maps straight to your DTO.

3) Turn off tracking for reads

The change tracker is great when you update entities. For reads, it’s extra work.

// Global default to no‑tracking for queries from this context
builder.Services.AddDbContextPool<AppDbContext>(opt =>
    opt.UseSqlServer(connString)
       .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

// Switch to tracking only when you need to update
var user = await ctx.Users.AsTracking().FirstAsync(u => u.Id == id);

For graphs where you still need identity resolution without tracking, use:

var withIdentity = await ctx.Posts
    .AsNoTrackingWithIdentityResolution()
    .Include(p => p.Tags)
    .ToListAsync();

4) Avoid N+1: disable lazy loading and use includes or explicit joins

Lazy loading proxies look handy, but they tend to fire many small queries.

// Don’t enable proxies unless you know the cost
// services.AddDbContext<AppDbContext>(o => o.UseLazyLoadingProxies());

// Prefer shape‑aware queries
var result = await ctx.Posts
    .Where(p => p.Published)
    .OrderByDescending(p => p.CreatedAt)
    .Include(p => p.Author)
    .Include(p => p.Tags)
    .Take(20)
    .Select(p => new PostListItem { /* ... */ })
    .ToListAsync();

When including multiple collections, use split queries to avoid huge Cartesian products:

var r = await ctx.Posts
    .AsSplitQuery() // one query per include path
    .Include(p => p.Tags)
    .Include(p => p.Comments)
    .Where(p => p.Published)
    .ToListAsync();

5) Compiled queries for hot paths

For endpoints hit thousands of times per minute, reduce LINQ translation overhead with compiled queries.

public static class Queries
{
    public static readonly Func<AppDbContext, int, IAsyncEnumerable<Post>> RecentByAuthor =
        EF.CompileAsyncQuery((AppDbContext db, int authorId) =>
            db.Posts
              .Where(p => p.AuthorId == authorId && p.Published)
              .OrderByDescending(p => p.CreatedAt)
              .Take(50));
}

// Usage
await foreach (var p in Queries.RecentByAuthor(ctx, authorId))
{
    // …
}

This cuts per‑call CPU and allocations for the same query shape.

6) Batch writes and reduce SaveChanges() calls

Each SaveChanges() is a transaction and roundtrip. Group changes.

// Bad: many roundtrips
foreach (var dto in dtos)
{
    ctx.Add(Map(dto));
    await ctx.SaveChangesAsync();
}

// Better: one SaveChanges
foreach (var dto in dtos)
{
    ctx.Add(Map(dto));
}
await ctx.SaveChangesAsync();

For very large batches:

ctx.ChangeTracker.AutoDetectChangesEnabled = false;
try
{
    foreach (var chunk in dtos.Chunk(1000))
    {
        foreach (var dto in chunk) ctx.Add(Map(dto));
        await ctx.SaveChangesAsync();
        ctx.ChangeTracker.Clear();
    }
}
finally { ctx.ChangeTracker.AutoDetectChangesEnabled = true; }

Note: ExecuteUpdate/ExecuteDelete (set‑based) can update or delete rows without loading entities. Great for maintenance tasks.

// Update many rows without materializing
await ctx.Posts
    .Where(p => p.Score < 0)
    .ExecuteDeleteAsync();

7) DbContext lifetime and pooling

Creating a context is cheap, but not free. Pooling helps cut GC pressure.

builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseSqlServer(connString));

Use scoped lifetime per request. Do not hold a context as a singleton or across threads.

  • Keep the context short‑lived.
  • Avoid passing it into background threads; create a new scope instead.

8) SQL‑first thinking: indexes, sargable predicates, data types

EF Core is a mapper; the database still does the heavy lifting.

  • Indexes: add them in migrations for columns used in Where/Join/OrderBy.
  • Sargable filters: avoid wrapping columns in functions (WHERE LOWER(Name) = ...). Use normalized columns or computed columns.
  • Data types: match .NET types to DB types (no nvarchar(max) for short strings, no decimal(38, 38) for money, etc.).
  • Foreign keys: index them.

Example migration snippet:

migrationBuilder.CreateIndex(
    name: "IX_Posts_AuthorId_CreatedAt",
    table: "Posts",
    columns: new[] { "AuthorId", "CreatedAt" });

9) Parameterization and raw SQL

LINQ queries are parameterized by default. If you need raw SQL, keep it safe and fast:

// Interpolated = parameterized
var active = await ctx.Users
    .FromSqlInterpolated($"SELECT * FROM Users WHERE IsActive = {isActive}")
    .ToListAsync();

Avoid concatenating strings. Prefer stored procedures for complex read models if they are stable and tested.

10) No client eval: keep work on the server

Make sure filters run in SQL, not in memory.

// Bad: Brings all rows to memory, then filters
var data = (await ctx.Posts.ToListAsync()).Where(p => p.Score > 10);

// Better: Filter in SQL, then materialize
var data2 = await ctx.Posts.Where(p => p.Score > 10).ToListAsync();

Call AsEnumerable() only after server‑side filters and ordering are applied.

11) Cache hot, rarely changing data

For small reference sets (e.g., countries, role maps), cache DTOs in memory.

public class CountryService(IMemoryCache cache, AppDbContext ctx)
{
    private const string Key = "countries-v1";

    public async Task<IReadOnlyList<CountryDto>> GetAll()
        => await cache.GetOrCreateAsync(Key, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(6);
            return await ctx.Countries
                .OrderBy(c => c.Name)
                .Select(c => new CountryDto(c.Id, c.Name))
                .ToListAsync();
        });
}

Cache at the edge if possible (response caching for public endpoints).

12) Compiled models (startup boost)

Large models slow down startup due to model building. Precompile it.

// 1) Add EF tools (if not added)
// <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="…" PrivateAssets="All" />

// 2) Run once per project (from the csproj directory):
// dotnet ef dbcontext optimize

// 3) Use the generated compiled model in your DbContext

This cuts cold start and reduces memory used by the model metadata.

13) Connection pooling and timeouts

  • Use the default ADO.NET pool; avoid opening/closing connections manually for each small operation.
  • Set a reasonable command timeout for long reports; don’t leave the default for everything.
ctx.Database.SetCommandTimeout(TimeSpan.FromSeconds(30));

14) Map only needed navigation properties

If a navigation is never used, don’t map it or mark it ignored. Smaller models translate faster.

modelBuilder.Entity<Post>().Ignore(p => p.LegacyField);

15) DTO mapping: Select straight to DTOs

Avoid mapping entities to DTOs with extra passes. Project directly in LINQ (shown earlier). For cases where mapping logic is complex, consider compiling expression maps and reusing them.

16) Transactions: keep them short

Wrap only what must be atomic. Long transactions hold locks and slow everyone else.

await using var tx = await ctx.Database.BeginTransactionAsync();
// … do the minimum
await ctx.SaveChangesAsync();
await tx.CommitAsync();

17) When to use a read replica

If reads dominate and you already optimized queries, offload to replicas. Route read‑only endpoints to a replica connection string. Keep eventual consistency in mind.

18) Tooling setup checklist

  • Enable EF Core logging at Information level in non‑prod for a while.
  • Add a slow query interceptor to flag commands over, say, 250 ms.
  • Export query plans for the worst offenders and fix missing indexes.
  • Use BenchmarkDotNet for microbenchmarks of hot code paths.
  • Add profiling (MiniProfiler, etc.) to see roundtrips per page.

19) Sample real‑world refactor

I had an API returning /feed in ~900 ms P95 under load. After a short session:

  1. Moved from entity load to DTO projection.
  2. Set UseQueryTrackingBehavior(NoTracking) globally.
  3. Replaced an include graph with a single projection + counts via GroupBy.
  4. Added a missing compound index (AuthorId, CreatedAt).
  5. Batched writes from 20 SaveChanges() to 1 per request.

Result: ~220 ms P95, DB CPU dropped by 35%, and fewer timeouts under peak.

Anti‑patterns to watch for

  • Returning full entities from controllers.
  • Calling .ToList() too early, then filtering.
  • Enabling lazy loading globally.
  • Doing work inside a long transaction.
  • Catching DbUpdateConcurrencyException and retrying without backoff.
  • Ignoring missing indexes in query plans.

FAQ: common EF Core performance questions

Should I always use AsNoTracking()?

Use it by default for reads. Switch to tracking only when you plan to modify entities.

Is AddDbContextPool safe?

Yes for most apps. Do not store per‑request state on the context. Treat it as transient.

Are includes bad?

Includes are fine when you actually need related data for many rows. Prefer projection when you need a shape different from your entity graph.

Does compiled query help if parameters change?

Yes. The query shape is compiled; parameters are passed in each call.

When to use raw SQL?

When a query is too specific or uses DB features not modeled in LINQ. Keep it parameterized.

Is ExecuteUpdate/Delete reliable for business logic?

Great for set‑based maintenance and simple updates. If logic is complex, load entities or use stored procedures.

What about bulk libraries?

They help with massive inserts/updates. Test carefully and watch transaction size.

Conclusion: cut query time with simple moves

You don’t need a full rewrite to get speed. Start with projections, no‑tracking reads, batched writes, proper indexes, and context pooling. Measure with logs and interceptors, fix N+1 issues, and keep transactions short. Your app will feel much snappier by the end of the day.

Which tip gave you the biggest win? Share your before/after numbers in the comments.

Leave a Reply

Your email address will not be published. Required fields are marked *