Performance Tuning Entity Framework Core at Scale cover image

Performance Tuning Entity Framework Core at Scale

By Julian Krause · Thursday, April 10, 2025 · ~3 min read

Entity Framework Core is remarkably capable, but it has performance characteristics that only become apparent when you are handling thousands of requests per second or querying tables with millions of rows. These are the optimizations that made the biggest difference in our production systems.

Use AsNoTracking for Read-Only Queries

The change tracker is one of EF Core's most expensive features. For every entity loaded with tracking, EF Core stores a snapshot of the original values and monitors the entity for changes. If you are just reading data to return in an API response, this overhead is pure waste.

// Slow — tracks every entity
var orders = await _context.Orders
    .Include(o => o.Lines)
    .Where(o => o.Status == OrderStatus.Active)
    .ToListAsync();

// Fast — no tracking overhead
var orders = await _context.Orders
    .AsNoTracking()
    .Include(o => o.Lines)
    .Where(o => o.Status == OrderStatus.Active)
    .ToListAsync();

// Even better — project to DTOs, no tracking needed
var orders = await _context.Orders
    .Where(o => o.Status == OrderStatus.Active)
    .Select(o => new OrderSummaryDto
    {
        Id = o.PublicId,
        Total = o.Lines.Sum(l => l.Quantity * l.UnitPrice),
        LineCount = o.Lines.Count
    })
    .ToListAsync();

The projection approach is the fastest because EF Core generates a single SQL query that only selects the columns you need. No entity materialization, no tracking, no unnecessary data transfer.

Avoid the N+1 Query Problem

The N+1 problem is the most common EF Core performance issue. It happens when you load a collection of entities and then access a navigation property on each one, triggering a separate query per entity.

// N+1 — one query for blogs, then one query PER blog for posts
var blogs = await _context.Blogs.ToListAsync();
foreach (var blog in blogs)
{
    // This triggers a lazy-load query for each blog
    Console.WriteLine($"{blog.Name}: {blog.Posts.Count} posts");
}

// Fixed — eager load with Include
var blogs = await _context.Blogs
    .Include(b => b.Posts)
    .ToListAsync();

// Better for large datasets — use a projection
var blogStats = await _context.Blogs
    .Select(b => new { b.Name, PostCount = b.Posts.Count })
    .ToListAsync();

Compiled Queries for Hot Paths

For queries that execute thousands of times per second, compiled queries eliminate the overhead of expression tree parsing and SQL generation. The query is compiled once and reused.

public static class CompiledQueries
{
    public static readonly Func<AppDbContext, Guid, Task<Order?>> GetOrderById =
        EF.CompileAsyncQuery(
            (AppDbContext context, Guid publicId) =>
                context.Orders
                    .AsNoTracking()
                    .Include(o => o.Lines)
                    .FirstOrDefault(o => o.PublicId == publicId));
}

// Usage in a controller
var order = await CompiledQueries.GetOrderById(_context, orderId);

Batch Operations with ExecuteUpdate and ExecuteDelete

EF Core 7 introduced bulk operations that execute directly in the database without loading entities into memory. For operations that affect many rows, this is orders of magnitude faster.

// Old way — loads every entity into memory
var oldOrders = await _context.Orders
    .Where(o => o.CreatedAt < cutoffDate)
    .ToListAsync();
_context.Orders.RemoveRange(oldOrders);
await _context.SaveChangesAsync();

// New way — single SQL DELETE statement
await _context.Orders
    .Where(o => o.CreatedAt < cutoffDate)
    .ExecuteDeleteAsync();

// Bulk update without loading entities
await _context.Orders
    .Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < staleDate)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Status, OrderStatus.Cancelled)
        .SetProperty(o => o.CancelledAt, DateTimeOffset.UtcNow));

The key takeaway: profile before you optimize. Use EF Core's built-in logging (EnableSensitiveDataLogging and EnableDetailedErrors in development) and tools like MiniProfiler to identify the actual bottlenecks before reaching for these techniques.


Comments (2)

David Thursday, February 26, 2026 5:08 PM

Could you elaborate on this topic in a follow-up post?

Felix Tuesday, February 24, 2026 8:08 PM

I had the same experience, can confirm.

Greta Wednesday, February 25, 2026 8:08 PM

I agree, great article!

Elena Friday, February 27, 2026 5:08 PM

This is exactly what I was looking for, thank you!

Leave a Comment