Most EF Core slowdowns are not really EF Core’s fault. In practice the culprit is a missing projection, an accidental N+1 query, or change tracking doing work nobody asked for. Milan Jovanovic put together a curated hub on his blog that maps out EF Core performance techniques from quick wins to specialized optimizations, and it works well as a checklist for any .NET team running EF Core in production.
This piece walks through that same map in more depth, with working code for each technique. I have used most of these patterns on real projects, so along with the how I have added notes on when a technique is worth the extra complexity and when it is not.
Why EF Core Performance Deserves Attention Early
Entity Framework Core is the default ORM for most new .NET applications, and for good reason. It hides a lot of SQL plumbing and lets a team move fast. That same convenience is what causes trouble: a LINQ query that looks innocent can generate a dozen round trips to the database, or pull back columns nobody needed, and none of that shows up until the application is under real load.
The fix is rarely a wholesale rewrite. Most of the time it is choosing the right query shape, turning off tracking where it is not needed, and measuring the generated SQL instead of guessing. I would recommend logging the SQL EF Core generates during development, at least for endpoints that touch more than a couple of tables.
Start With the Query Shape
Before reaching for caching or a different data access library, look at how the query itself is written. A projection that pulls only the columns a screen needs will almost always outperform loading full entities and mapping them in memory afterward.
// Loads full entities, tracks them, and maps in memory
var orders = await context.Orders
.Include(o => o.Customer)
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
var result = orders.Select(o => new OrderSummaryDto(
o.Id, o.Customer.Name, o.Total)).ToList();
// Projects directly into the DTO, no tracking, fewer columns
var result = await context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.Select(o => new OrderSummaryDto(o.Id, o.Customer.Name, o.Total))
.AsNoTracking()
.ToListAsync();
The second version generates SQL that selects only the three columns the DTO needs, and it skips the overhead of setting up change tracking for entities that will never be updated. AsNoTracking is safe for any read-only query. The only time to leave it off is when you plan to modify the entity and call SaveChanges in the same DbContext scope.
Splitting Queries to Avoid Cartesian Explosion
When a query includes more than one collection navigation, EF Core by default joins everything into a single SQL statement. If an order has ten line items and three shipments, that join can multiply rows and send back far more data than the entity graph actually contains.
var orders = await context.Orders
.Include(o => o.LineItems)
.Include(o => o.Shipments)
.AsSplitQuery()
.ToListAsync();
AsSplitQuery tells EF Core to issue one query per collection instead of a single joined query, which avoids the row multiplication problem. It is not automatically the faster option though: split queries mean multiple round trips to the database, and if another transaction commits between those round trips you can end up reading data that is not perfectly consistent. Check the generated SQL for both shapes and measure against your actual entity graph before deciding which one to keep.
Compiled Queries for Hot Paths
EF Core compiles a LINQ expression into SQL on every call unless you tell it otherwise. For an endpoint hit thousands of times a second, that repeated compilation adds measurable overhead even though each individual compile is fast.
private static readonly Func<AppDbContext, int, Task<Customer?>> GetCustomerById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Customers.FirstOrDefault(c => c.Id == id));
var customer = await GetCustomerById(context, customerId);
EF.CompileAsyncQuery caches the translated query the first time it runs and reuses that plan on every subsequent call. This only pays off on genuinely hot paths where the database work itself is cheap. If a query is already dominated by network latency or a slow index scan, compiling it will not move the needle, so benchmark before adding this to every repository method.
N+1 Queries and Loading Strategies
The N+1 problem shows up when a query loads a list of entities and then, for each entity, triggers a separate query to fetch related data. It usually creeps in through lazy loading or a foreach loop that touches a navigation property without eager loading it first.
EF Core gives you three ways to load related data: eager loading with Include, explicit loading by calling Load on a navigation entry, and lazy loading through proxies. Eager loading is the right default for anything read heavy, because it turns N+1 round trips into one or two. Lazy loading is convenient during prototyping but easy to trip over in production code, since a single extra property access in a view can silently add a database round trip.
Choosing Between EF Core and Dapper
EF Core is not the only way to talk to a database, and for some workloads it is not the fastest one either. Dapper is a lightweight micro-ORM that maps query results to objects without change tracking, LINQ translation, or a model layer to maintain.
I reach for EF Core by default because of the productivity it gives on CRUD-heavy screens, migrations, and change tracking. For reporting queries, dashboards, or anything that reads a large result set once and never updates it, Dapper with hand-written SQL is usually noticeably faster and easier to tune. A pragmatic setup uses EF Core for the write side of an application and Dapper for read-only reporting queries, rather than treating it as an all-or-nothing choice.
Handling Bulk Inserts and Updates
SaveChanges works fine for a handful of rows, but it does not scale to inserting or updating thousands of records at once, because EF Core issues a separate round trip, or a small batch of them, for each tracked change. EF Core 7 introduced ExecuteUpdate and ExecuteDelete specifically to close this gap.
await context.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired));
This translates to a single UPDATE statement executed directly on the database, with no entities loaded into memory and no change tracker involved. ExecuteUpdate does not help with bulk inserts of new data, since there is nothing to update yet. For inserting large datasets, SqlBulkCopy or a library such as EFCore.BulkExtensions is still the better tool, because it can push tens of thousands of rows in one batch instead of one INSERT per row.
Optimistic and Pessimistic Locking
When two requests can update the same row at the same time, you need a strategy to stop one from silently overwriting the other. EF Core supports optimistic concurrency out of the box through a concurrency token, typically a rowversion column in SQL Server.
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = default!;
}
With RowVersion mapped as a concurrency token, EF Core adds a WHERE clause on that column to every UPDATE it generates. If another transaction changed the row in between, zero rows match and EF Core throws a DbUpdateConcurrencyException, which the application can catch and handle, usually by reloading the entity and asking the user to retry. Pessimistic locking, using something like SELECT FOR UPDATE, is the alternative for cases where retries are not acceptable, such as decrementing limited inventory, but it holds a database lock for the duration of the transaction and can hurt throughput under contention if used broadly.
Interceptors and Other Advanced Features
EF Core’s interceptor pipeline lets you hook into command execution, connection opening, and SaveChanges, without touching every repository method individually. A common use is logging slow commands or automatically stamping audit columns like ModifiedAt on every save.
Beyond interceptors, EF Core also supports raw SQL queries for cases LINQ cannot express cleanly, soft delete through a global query filter, multi-tenant applications sharing one schema, and running multiple DbContext types in a single application. DbContext pooling is worth calling out specifically: it reuses DbContext instances from a pool instead of allocating a new one per request, which reduces allocation pressure on high-traffic APIs, though it does mean you cannot rely on constructor-time state persisting between requests.
What EF Core 10 Adds for Performance
EF Core 10, shipping alongside .NET 10, adds a couple of features that used to require manual workarounds. LeftJoin and RightJoin are now first-class LINQ operators instead of something you had to fake with GroupJoin and SelectMany.
var results = context.Customers
.LeftJoin(
context.Orders,
customer => customer.Id,
order => order.CustomerId,
(customer, order) => new { customer.Name, order })
.ToList();
This reads far closer to the SQL LEFT JOIN it produces than the old GroupJoin plus DefaultIfEmpty pattern did, and it is easier for a team to review in a pull request. EF Core 10 also adds named query filters, which let a single entity carry more than one global query filter, for example one for soft delete and a separate one for tenant isolation, and lets you disable them individually with IgnoreQueryFilters by name instead of turning off every filter at once.
Keeping Migrations Safe in Production
Query performance gets most of the attention, but a bad migration can take an application down just as effectively as a slow query. Adding a NOT NULL column without a default, or renaming a column that a running instance still expects, are the two mistakes I see most often in this area.
The safer pattern for a zero-downtime deployment is to split a schema change into backward-compatible steps: add the new column as nullable first, backfill it, deploy the code that uses it, and only then tighten the constraint in a later migration. It takes more deployments to get there, but it means the old and new versions of the application can run against the same schema during a rolling deployment.
Where to Start
If you only take one thing from this guide, start by logging the generated SQL and measuring the actual slow query against the database, rather than guessing which LINQ pattern is at fault. Fix N+1 round trips first, then move to projections and AsNoTracking for read-only paths, and only reach for compiled queries, custom interceptors, or bulk operation libraries once you have confirmed with real measurements that they solve a problem you actually have.
Leave a Reply