Entity Framework Core Performance: 8 Common Mistakes and How to Fix Them

Entity Framework Core is productive, and with a few habits it is also fast. Most "EF Core is slow" problems I have seen in production come from the same handful of patterns, and each one has a simple fix. Here are the eight that matter most.

Entity Framework Core performance mistakes

1. Tracking queries you only read

By default, EF Core tracks every entity it loads so it can detect changes. For read-only screens and APIs that is wasted memory and CPU.

var employees = await db.Employees
    .AsNoTracking()
    .Where(e => e.DepartmentId == deptId)
    .ToListAsync();

If a whole context is read-only, set QueryTrackingBehavior.NoTracking as its default.

2. The N+1 query problem

Loading a list and then touching a navigation property for each item, especially with lazy loading enabled, runs one query for the list plus one 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);

Load what you need up front with Include, or better, project only the fields you need (see mistake 4).

3. Cartesian explosion from multiple Include calls

Including two or more collections in one query joins them all together, so the number of rows returned can multiply dramatically:

var orders = await db.Orders
    .Include(o => o.Lines)
    .Include(o => o.Payments)
    .AsSplitQuery()          // one SQL query per collection
    .ToListAsync();

AsSplitQuery() runs a separate query per collection instead of one huge join. Measure both ways: split queries mean more round trips, so they help most when collections are large.

4. Loading whole entities when you need three columns

Selecting full entities pulls every column, including large text fields, and tracks them. Project into a small type instead:

var list = await db.Employees
    .Where(e => e.IsActive)
    .Select(e => new EmployeeListItem(e.EmployeeId, e.FullName, e.Department.Name))
    .ToListAsync();

Projections are not tracked, read fewer columns, and let SQL Server use narrower covering indexes.

5. Filtering in memory instead of in SQL

Calling ToList(), AsEnumerable() or ToListAsync() too early loads the whole table and filters in C#:

// Bad: loads every employee, then filters in memory
var bad = (await db.Employees.ToListAsync())
          .Where(e => e.HireDate.Year == 2026);

// Good: filter runs in SQL
var good = await db.Employees
    .Where(e => e.HireDate >= new DateOnly(2026, 1, 1))
    .ToListAsync();

The second version also avoids wrapping the column in a function, so SQL Server can use an index on HireDate.

6. Calling SaveChanges in a loop

Each SaveChanges call is a round trip and its own transaction. Add or update everything first, then save once (or in batches of a few thousand for very large imports):

foreach (var item in items)
    db.PayrollLines.Add(item);

await db.SaveChangesAsync();   // EF Core batches the INSERTs

7. Loading rows just to update or delete them

For bulk changes, loading entities into memory only to modify and save them is slow. Since EF Core 7, ExecuteUpdate and ExecuteDelete run a single SQL statement directly:

await db.Employees
    .Where(e => e.DepartmentId == oldDeptId)
    .ExecuteUpdateAsync(s => s.SetProperty(e => e.DepartmentId, newDeptId));

await db.AuditLogs
    .Where(a => a.CreatedAt < cutoff)
    .ExecuteDeleteAsync();

These bypass the change tracker, so entities already loaded in the context will not reflect the change. For very large deletes, batch them to avoid long locks.

8. Never looking at the generated SQL

You cannot tune what you cannot see. Log the SQL EF Core sends, at least in development:

optionsBuilder
    .UseSqlServer(connectionString)
    .LogTo(Console.WriteLine, LogLevel.Information)
    .EnableSensitiveDataLogging();   // development only: shows parameter values

You can also call ToQueryString() on any LINQ query to see its SQL. Then take the slow statements to SQL Server and check their execution plans like any other query.

Bonus tips

  • Use AnyAsync() instead of CountAsync() > 0 to check existence.
  • Use AddDbContextPool in high-throughput web apps to reuse context instances.
  • Use FromSql with interpolated parameters (not string concatenation) for raw SQL, to stay safe from SQL injection.
  • Make sure your indexes match the filters EF Core generates.

Related reading:

Comments

Popular posts from this blog

SARGable Queries in SQL Server: Why Functions in WHERE Slow You Down

Azure SQL Database vs SQL Server on Azure VM: Cost Comparison (2026)

How to Read a SQL Server Execution Plan (Beginner's Guide)