# EF Core Performance Tuning: What Actually Moves the Needle in Production

Entity Framework Core makes it easy to get an application working. It does not automatically make that application fast. I've spent a good chunk of my career optimizing systems where the biggest performance wins had nothing to do with the business logic and everything to do with how the application talked to its database, everything from an e-commerce insurance platform handling growing transaction volume to a financial aid portal serving thousands of cooperative members.

Here's what has consistently made the biggest difference, in the order I usually tackle it.

## 1\. Stop fetching more than you need

The single most common mistake is pulling entire entities when only a few fields are actually used. `Select()` projections exist for exactly this reason:

```csharp
var summaries = await context.Orders
    .Where(o => o.CustomerId == customerId)
    .Select(o => new OrderSummary { Id = o.Id, Total = o.Total, Status = o.Status })
    .ToListAsync();
```

This isn't a minor optimization. On a platform processing high transaction volume, cutting unnecessary column and relationship loading was one of the changes that most directly reduced load times. Less data crossing the wire means less time spent, full stop.

## 2\. Understand your tracking behavior

By default, EF Core tracks every entity it loads so it can detect changes. That's necessary when you intend to update the entity. It's pure overhead when you're only reading.

```csharp
var products = await context.Products
    .AsNoTracking()
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync();
```

For read-heavy endpoints, and most APIs are read-heavy, `AsNoTracking()` should be the default, not the exception. I've seen this alone cut response times noticeably on endpoints that return large result sets.

## 3\. Kill the N+1 problem before it kills you

This is the one that quietly destroys performance at scale. You load a list of entities, then loop through them accessing a related property, and EF Core fires a separate query for every single item.

```csharp
// This fires one query per order
foreach (var order in orders)
{
    var items = order.OrderItems;
}
```

The fix is to load what you need up front:

```csharp
var orders = await context.Orders
    .Include(o => o.OrderItems)
    .ToListAsync();
```

On a case management system I worked on for legal case processing, tracking down N+1 queries buried in what looked like innocent loops was consistently the highest-leverage performance work available. One `.Include()` in the right place did more than hours of unrelated tuning.

## 4\. Be deliberate about what "eager loading" actually loads

`.Include()` solves N+1, but overusing it introduces a different problem: cartesian explosion, where joining multiple collection navigations multiplies your result set unnecessarily. `AsSplitQuery()` breaks a single query with multiple includes into several smaller ones, which is often faster once you have more than one collection navigation involved:

```csharp
var orders = await context.Orders
    .Include(o => o.OrderItems)
    .Include(o => o.Payments)
    .AsSplitQuery()
    .ToListAsync();
```

There's no universal answer here. I test both approaches on realistic data volumes rather than assuming one is always better.

## 5\. Index what you actually query on

This one isn't EF Core specific, but EF Core makes it easy to forget because the ORM hides the SQL being generated. If you're filtering, sorting, or joining on a column regularly, it needs an index. Use `dotnet ef migrations` to add indexes explicitly through the model configuration rather than hoping the database figures it out:

```csharp
modelBuilder.Entity<Order>()
    .HasIndex(o => o.CustomerId);
```

I make it a habit to periodically review actual query execution plans against production-like data, not just trust that the ORM is doing something sensible.

## 6\. Batch your writes

Saving changes one entity at a time inside a loop generates a round trip per save. Accumulate changes and call `SaveChangesAsync()` once, or use EF Core's bulk extensions for genuinely large batch operations:

```csharp
foreach (var item in items)
{
    context.Add(item);
}
await context.SaveChangesAsync();
```

For very large datasets, EF Core's batching still has limits, and that's when I reach for raw bulk insert libraries instead of fighting the ORM.

## 7\. Cache what doesn't change often

Not every performance problem is a query problem. Reference data, configuration values, anything that changes infrequently, doesn't need to hit the database on every request. A short-lived in-memory or distributed cache in front of that data removes load from the database entirely for the most frequently accessed, least volatile information.

## The real takeaway

None of this is exotic. It's mostly discipline: know what you're fetching, know when you need tracking, know when a loop is quietly generating a hundred queries instead of one. EF Core gives you enormous productivity, but it will happily let you write slow code if you're not paying attention to what it's actually doing under the hood. The teams that get the most out of it are the ones that occasionally stop and look at the generated SQL instead of trusting the abstraction blindly.
