6 Entity Framework Core Performance Pitfalls (and How to Fix Them)
Avoid these 6 common Entity Framework Core performance pitfalls in .NET — with real-world scenarios, root causes, and refactors.
Entity Framework Core makes database access feel effortless. Write some C# code, and EF handles the SQL. But this convenience comes with a trap: it is easy to write code that looks fine and performs terribly at scale.
You do not notice when your table has 100 rows. When it has 100,000 rows, your API starts timing out.
In this article, I will walk through six common EF Core performance pitfalls that I see in code reviews regularly. For each one, I will show the problem code, explain why it is slow, and provide a fix.
Every number below was measured. I built a benchmark harness for exactly these six scenarios and ran it, and where the honest result is less dramatic than the folklore, I say so. The methodology and the caveats are at the end, in How these numbers were measured, and the harness itself is in the benchmark repository if you would like to reproduce or contradict any of it.
Pitfall 1: N+1 Queries (The Lazy Loading Trap)
The Problem
[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders()
{
var orders = await _context.Orders.ToListAsync();
var orderDtos = orders.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // N+1 query!
Items = o.Items.Select(i => new OrderItemDto // Another N+1!
{
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList()
}).ToList();
return Ok(orderDtos);
}
Count the statements carefully, because this is where the usual telling of this story goes wrong. Every order triggers two lazy loads, not one: touching o.Customer issues a query, and touching o.Items issues another. So for N orders the total is 1 + 2N, not 1 + N.
For the 600 orders in my benchmark:
- 1 query for the orders
- 600 queries for the customers, one per order
- 600 queries for the item collections, one per order
Total: 1,201 queries. If your DTO also reached into i.Product.Name, you would add one more query per item and the total would run into the thousands.
Why It Is Slow
Each query carries overhead: a round trip to the database, query parsing and execution, and result marshalling. On a client/server database the round trip dominates everything else, and 1,200 of them cannot be amortised away.
The Fix: Eager Loading with Include
[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.AsNoTracking() // Bonus optimization (see Pitfall 2)
.ToListAsync();
var orderDtos = orders.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Items = o.Items.Select(i => new OrderItemDto
{
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList()
}).ToList();
return Ok(orderDtos);
}
Total: 1 query (or 2 to 3 with AsSplitQuery, see Pitfall 6).
Performance Impact
The honest headline here is the statement count, not a stopwatch reading:
- Before: 1,201 SQL statements
- After: 1 SQL statement
That ratio is a property of the query shape. It does not depend on your database engine, your hardware, or your network.
The wall-clock number needs more care, and this is the claim I most wanted to check, because “60x faster” is the figure that circulates and I could not reproduce anything close to it. Against file-based SQLite, running in the same process as the application, 600 orders with 3,000 items measured 130 ms before and 23 ms after, so 5.6x. But that comparison changes two things at once. Turning on lazy loading also makes EF materialise every entity as a dynamic proxy subclass, and proxies are not free. So I added a third arm that keeps proxies switched on but uses Include, which isolates the two effects:
| Variant | Queries | Mean | Allocated |
|---|---|---|---|
| Lazy loading proxies, N+1 | 1,201 | 130.1 ms | 37.1 MB |
Lazy loading proxies, Include | 1 | 45.0 ms | 16.8 MB |
No proxies, Include | 1 | 23.0 ms | 6.4 MB |
Read the middle row and the split becomes obvious. Roughly 2.9x of the improvement comes from eliminating the round trips, and roughly 1.9x comes from no longer materialising proxies. Only the first of those is the N+1 pitfall. The often-quoted order-of-magnitude numbers are not what happens in-process.
They are, however, entirely plausible against a real database server, and the reason is arithmetic rather than optimism. SQLite is in-process, so a “query” there is a function call into a library and a round trip costs essentially nothing. Put PostgreSQL on another host with a 1 ms round-trip time and those 1,200 extra statements add about 1.2 seconds on their own, before the database has done any work at all. That is where a 10x or 50x figure comes from, and it scales with your network latency, not with your CPU. Measure it on your own infrastructure rather than trusting anyone’s multiplier, including mine.
When to Use Include
Use Include when:
- You always need the related data
- The relationship is one-to-one or one-to-many and the collection is not enormous
Avoid Include when:
- Related data is optional
- Collections are huge, in which case use projection instead, see Pitfall 3
And treat lazy loading itself with suspicion. It is the feature that turns a missing Include into 1,200 silent queries, and as the table above shows, it costs you proxy materialisation on top.
Pitfall 2: Tracking Everything
The Problem
[HttpGet("products")]
public async Task<ActionResult<List<ProductDto>>> GetProducts()
{
// EF Core tracks all entities by default
var products = await _context.Products.ToListAsync();
return Ok(products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
}));
}
Why It Is Slow
By default, the change tracker monitors every entity that a query returns. It allocates tracking entries, takes snapshots of the original values, and watches for modifications. For a read-only query, all of that work is thrown away the moment the request ends.
The Fix: AsNoTracking
[HttpGet("products")]
public async Task<ActionResult<List<ProductDto>>> GetProducts()
{
var products = await _context.Products
.AsNoTracking() // Disable change tracking
.ToListAsync();
return Ok(products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
}));
}
Performance Impact
Loading 10,000 products, tracked versus AsNoTracking:
- Time: 1.84x to 1.98x faster across four runs, median 1.93x. On the published run, 33.7 ms fell to 18.3 ms.
- Allocation: 16.65 MB fell to 8.06 MB, which is 51.6% less.
The allocation figure is the one I trust most in this entire article. It came out byte-identical on all four runs, because it is not a timing at all: it is the memory the change tracker needs for 10,000 snapshot entries, and that is deterministic. The timing moves around a little with the machine’s mood. The allocation does not.
So “about 2x faster and roughly half the allocation” is a fair summary, and notice that the memory saving is the larger and more dependable of the two effects.
When to Use AsNoTracking
Use AsNoTracking when:
- The query is read-only
- You will not call
SaveChangeson these entities - You are building DTOs or view models
Do not use it when:
- You need to update the entities you loaded
- You rely on change tracking features such as identity resolution
Global No-Tracking (Opt-In Tracking)
If most of your queries are read-only, flip the default and opt back in where you need it:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
// Change default behavior
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Product> Products => Set<Product>();
// Attach explicitly when you do need to write
public async Task<int> UpdateProductAsync(Product product)
{
Attach(product).State = EntityState.Modified;
return await SaveChangesAsync();
}
}
Note that this method lives inside the DbContext, so it calls Attach and SaveChangesAsync directly. There is no _context field to go through: the context is this. If you would rather put the method on a repository or a service, then it does need an injected AppDbContext, and the _context. prefix comes back. Mixing the two conventions is a reliable way to produce code that does not compile.
Pitfall 3: Loading Entire Entities When You Need a Few Columns
The Problem
[HttpGet("product-names")]
public async Task<ActionResult<List<string>>> GetProductNames()
{
var products = await _context.Products.ToListAsync();
return Ok(products.Select(p => p.Name).ToList());
}
If Product has twenty columns including large text or BLOB fields, you are reading, transferring, and materialising all of it to use one string.
Why It Is Slow
- The database reads columns you will never look at
- The provider decodes them into CLR objects
- Those objects sit in memory until the GC takes them
- On a client/server engine, every one of those bytes also crosses the network
The Fix: Select Projection
[HttpGet("product-names")]
public async Task<ActionResult<List<string>>> GetProductNames()
{
var names = await _context.Products
.Select(p => p.Name)
.ToListAsync();
return Ok(names);
}
Generated SQL:
-- Before
SELECT * FROM Products
-- After
SELECT Name FROM Products
More Complex Projection
[HttpGet("orders")]
public async Task<ActionResult<List<OrderSummaryDto>>> GetOrderSummaries()
{
var summaries = await _context.Orders
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // Joins automatically
ItemCount = o.Items.Count, // Aggregates in SQL
TotalAmount = o.Items.Sum(i => i.UnitPrice * i.Quantity)
})
.ToListAsync();
return Ok(summaries);
}
This generates a single efficient SQL query with joins and aggregates, and it never materialises an Order entity at all.
Performance Impact
This is the pitfall where the size of the payload does the talking. My benchmark loads 1,000 orders that each carry a 4,000-character description and a 6,000-byte blob, so roughly 10 KB per row, against a projection of four scalar columns:
- Time: 5.5x to 6.3x faster across runs, median 5.6x. On the published run, 8.0 ms fell to 1.27 ms.
- Managed allocation: 14,336 KB fell to 475 KB, about 30x less.
- Column data read: 9.57 MB fell to 37.3 KB, about 263x less.
That last line deserves a precise definition, because “transfers 10 MB” is the kind of phrase that sounds measured and is not. SQLite runs in-process, so nothing crosses a wire and I cannot honestly report network bytes. What I measured instead is the total length of the column values each query shape touches, summed with SUM(LENGTH(...)) over the 1,000 rows. It is the amount of column data the engine has to read and decode. On PostgreSQL or SQL Server you would pay a comparable ratio again on the wire, and then again in your application’s memory, which is exactly why the effect compounds so badly in production.
The 30x allocation drop is managed memory in your process, and it is the number an application developer can act on directly.
When to Use Projection
Always prefer projection for:
- DTOs returned from APIs
- View models for a UI
- Reports and exports
- Any read-only query
Pitfall 4: Not Using Compiled Queries for Hot Paths
The Problem
public async Task<Product?> GetProductByIdAsync(int id)
{
return await _context.Products.FirstOrDefaultAsync(p => p.Id == id);
}
Every call walks the LINQ expression tree, translates it, and looks the result up in EF’s query cache. The cache means you are not regenerating SQL from scratch every time, but you are still paying to build and hash the expression on every single call.
Why It Is Slow
For a query invoked thousands of times per second, that per-call translation work becomes a measurable share of the request. It is pure overhead: the SQL it produces is identical every time.
The Fix: Compiled Queries
private static readonly Func<AppDbContext, int, Task<Product?>> _getProductById =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Products.FirstOrDefault(p => p.Id == id));
public async Task<Product?> GetProductByIdAsync(int id)
{
return await _getProductById(_context, id);
}
The delegate is built once, and every later call skips straight to executing it.
More Complex Example
private static readonly Func<AppDbContext, DateTime, DateTime, IAsyncEnumerable<Order>> _getOrdersByDateRange =
EF.CompileAsyncQuery((AppDbContext context, DateTime start, DateTime end) =>
context.Orders
.Where(o => o.OrderDate >= start && o.OrderDate <= end)
.Include(o => o.Customer)
.OrderByDescending(o => o.OrderDate));
public async Task<List<Order>> GetOrdersByDateRangeAsync(DateTime start, DateTime end)
{
var orders = new List<Order>();
await foreach (var order in _getOrdersByDateRange(_context, start, end))
{
orders.Add(order);
}
return orders;
}
Performance Impact
Ten thousand calls against a warm DbContext, plain LINQ versus EF.CompileQuery:
- Time: 34% to 42% faster across runs, median 38%. On the published run, 407 ms fell to 237 ms.
- Allocation: 133.6 MB fell to 83.0 MB, 1.61x less.
Two things are worth saying about that range. First, it is a range for a reason. This benchmark varied noticeably between runs, so quoting the best result of 42% and calling it “40% faster” would be picking a winner. The median is 38%.
Second, the context is deliberately warm: it is created once and reused for all 10,000 calls, so EF’s query cache is already hot and the plain-LINQ arm is not paying full SQL generation. That is the realistic hot-path scenario, and it is also the least flattering one for compiled queries. A cold context would show a far larger gap on the first call and roughly this gap afterwards. In other words, 38% is what you get once everything is warmed up, which is the state a production service spends its life in.
When to Use Compiled Queries
Use compiled queries for:
- High-frequency queries called hundreds of times per second or more
- API endpoints under sustained load
- Background jobs that run the same query in a loop
Do not bother for:
- Ad-hoc queries
- Queries whose shape changes with the input, such as dynamic filters
Pitfall 5: Missing Indexes Causing Table Scans
The Problem
public async Task<Customer?> GetCustomerByEmailAsync(string email)
{
return await _context.Customers
.FirstOrDefaultAsync(c => c.Email == email);
}
If Email is not indexed, the database has one option: look at every row.
Why It Is Slow
There is no cleverness available to the planner. With a million customers and no index, the engine reads rows until it finds a match, and if there is no match it reads all of them. SQLite says so in its own words. This is EXPLAIN QUERY PLAN on the two databases, which are identical except for the index:
-- Without the index
SCAN c
-- With the index
SEARCH c USING INDEX IX_Customers_Email (Email=?)
SCAN versus SEARCH is the same distinction SQL Server and PostgreSQL would draw between a table scan and an index seek. The planner output is engine-specific; the fact it reports is not.
The Fix: Add Index in Fluent API
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add index on Email
modelBuilder.Entity<Customer>()
.HasIndex(c => c.Email);
// Composite index for a common query
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt });
// Unique index
modelBuilder.Entity<User>()
.HasIndex(u => u.Username)
.IsUnique();
// Filtered index (SQL Server)
modelBuilder.Entity<Order>()
.HasIndex(o => o.Status)
.HasFilter("[Status] = 'Pending'");
}
}
Generate the migration:
dotnet ef migrations add AddIndexes
dotnet ef database update
Performance Impact
This is the pitfall that survives scrutiny best, and it is the only place in this article where an order-of-magnitude claim holds up under measurement. Two databases of 1,000,000 customers each, identical rows, differing only in IX_Customers_Email:
- Average lookup: 18.73 ms without the index, 49.0 µs with it. That is about 380x, and across runs the ratio ranged from 347x to 382x.
- Worst case: a lookup for an address that matches nothing has to read the entire table before it can answer “no”. That measured 41.9 ms without the index against 39.5 µs with it, which is roughly 1,000x. Two probes of this case landed at 930x and 1,060x, so treat it as a thousandfold rather than a precise figure.
The average figure needs one honest qualification. FirstOrDefault compiles to LIMIT 1, and an unindexed scan stops the instant it finds its row, so the cost of a missing index depends entirely on how deep into the table the answer sits. Looking up the first customer is nearly free; looking up the last one reads a million rows. My benchmark runs a fixed set of ten lookups spread evenly across the key range, which puts the mean scan depth at 45% of the table by construction. That is a defensible average, but it is a friendly one: real lookups for addresses that do not exist, such as a login attempt against an unknown account, always hit the worst case.
The other qualification runs the same direction. The unindexed table is 78 MB on a machine with 46 GiB of RAM, so it is entirely resident in the OS page cache and every “scan” is a memory scan. On a server whose working set does not fit in its buffer pool, the same scan starts touching disk and the “before” number gets dramatically worse. Both caveats mean the measured ratios are floors.
When to Add Indexes
Index the columns used in:
WHEREclausesJOINconditionsORDER BYclauses- Foreign keys, though EF Core creates those indexes for you
Do not over-index:
- Every index slows down
INSERT,UPDATE, andDELETE - Every index costs disk space
- Too many indexes give the query planner more ways to choose badly
Finding Missing Indexes
Turn on query logging in development and read the plans for the queries that matter:
// Enable sensitive data logging in development only
optionsBuilder.EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information);
Then take the generated SQL to your database and ask it what it intends to do: EXPLAIN QUERY PLAN on SQLite, EXPLAIN ANALYZE on PostgreSQL, or an execution plan in SQL Server. You are looking for the word “scan” on a large table.
Pitfall 6: Cartesian Explosion with Multiple Includes
The Problem
public async Task<List<Order>> GetOrdersWithDetailsAsync()
{
return await _context.Orders
.Include(o => o.Items) // 10 items per order
.Include(o => o.Payments) // 2 payments per order
.Include(o => o.Shipments) // 3 shipments per order
.ToListAsync();
}
Why It Is Slow
EF Core generates a single query with several LEFT JOINs, and joining three sibling collections produces their Cartesian product. For one order with 10 items, 2 payments, and 3 shipments, the result set is 10 × 2 × 3 = 60 rows, each one repeating the order’s own columns.
For 100 such orders, the database returns 100 × 60 = 6,000 rows. EF de-duplicates them in memory into 100 orders, but only after every one of those rows has been produced, transferred, and materialised.
The Fix: AsSplitQuery
public async Task<List<Order>> GetOrdersWithDetailsAsync()
{
return await _context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.Shipments)
.AsSplitQuery() // Split into multiple queries
.ToListAsync();
}
This executes four statements instead of one:
- One query for the orders
- One for the items
- One for the payments
- One for the shipments
Now the row counts simply add up instead of multiplying: 100 orders + 1,000 items + 200 payments + 300 shipments.
Performance Impact
For those 100 orders, with AsSplitQuery as the only difference between the two variants:
- Rows returned: 6,000 before, 1,600 after. That is 3.75x fewer rows, and it is exact arithmetic rather than a measurement: 100 × 10 × 2 × 3 against 100 + 1,000 + 200 + 300.
- Time: 28.3 ms fell to 3.65 ms, 7.76x faster. This was the most stable benchmark in the set, landing between 7.4x and 7.9x on every run.
- Allocation: 6.92 MB fell to 1.40 MB, 4.94x less.
Two observations. The first is that the speedup is larger than the row ratio, 7.8x against 3.75x, because duplicated rows are not merely transferred, they also have to be decoded and merged back into a single object graph on arrival, and with tracking enabled the change tracker adds identity-map work on top. The second is that this pitfall, like the N+1 one, gets worse rather than better on a real database server, since those 4,400 surplus rows have to cross a network before they can be discarded.
When to Use AsSplitQuery
Use AsSplitQuery when:
- You are including more than one collection navigation
- The result sets are large
- You can see the row multiplication happening
Do not use it when:
- You are only including reference navigations, which do not multiply
- Result sets are small, where several round trips cost more than the duplication
Be aware of the trade-off: split queries are executed as separate statements, so without an explicit transaction they are not a consistent snapshot of the data.
Making Split Queries the Default
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
}
}
Override per query with .AsSingleQuery() when you need to.
Bonus: General Best Practices
1. Pagination
Never load an entire table:
// Bad
var products = await _context.Products.ToListAsync();
// Good
var products = await _context.Products
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
2. Async All the Way
// Bad: blocks the thread
var products = _context.Products.ToList();
// Good: async
var products = await _context.Products.ToListAsync();
3. Batch Updates
// Bad: N round trips
foreach (var product in products)
{
product.Price *= 1.1m;
await _context.SaveChangesAsync(); // Do not do this
}
// Good: one transaction
foreach (var product in products)
{
product.Price *= 1.1m;
}
await _context.SaveChangesAsync(); // Once, at the end
4. Use ExecuteUpdate for Bulk Changes (EF Core 7+)
// Instead of loading, modifying, and saving
await _context.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m));
A single SQL UPDATE, with nothing loaded and nothing tracked.
How These Numbers Were Measured
I did not want to publish another article full of round multipliers, so I built a harness that runs all six scenarios and reports whatever it finds. Several of the results came back smaller than the numbers this article used to quote. Those are the numbers above.
Environment. An Intel Core i7-11700K with 46 GiB of RAM, running Arch Linux. .NET SDK 10.0.110, EF Core 10.0.10, BenchmarkDotNet 0.15.8.
Database. SQLite, file-based, one database file per scenario, seeded deterministically from a fixed RNG seed so that a re-run produces identical files. The row counts are the ones each pitfall names: 600 orders with 3,000 items for the N+1 case, 10,000 products for tracking and compiled queries, 1,000 orders carrying roughly 10 KB of payload each for projection, a full 1,000,000 customers in each of two databases for the index case, and 100 orders with 10 items, 2 payments, and 3 shipments for the cartesian one.
Method. Each benchmark is a BenchmarkDotNet job with 3 warmup and 15 measured iterations, with MemoryDiagnoser enabled. The N+1 benchmark uses 10 warmup iterations instead, because it allocates 37 MB per operation and had not reached a steady state after 3. Where I report a range, it is across four separate runs of the whole suite; where I report a single figure, it is from the run whose full output is committed alongside the code. Pitfalls 1 and 5 were re-run on their own after the third arm and the worst-case arms were added, so their tables come from those targeted re-runs, which are committed alongside the four full-suite logs. Query counts, row counts, and query plans are collected separately through a DbCommandInterceptor, since those are counts rather than timings and do not need a benchmark at all.
Now the caveats, which matter more than the numbers.
SQLite runs in-process, so every time ratio here is a floor. There is no TCP connection, no TLS handshake, no serialisation, and no network latency. A “query” is a function call into a library. That makes the harness trivially reproducible, and it makes the two pitfalls that are fundamentally about round trips, N+1 and cartesian explosion, look far milder than they are in production. Add a millisecond of latency per statement and Pitfall 1’s 1,200 surplus queries cost more than a second by themselves.
The counts do not have that problem. 1,201 statements against 1, and 6,000 rows against 1,600, are properties of the query shape. They are identical on SQLite, PostgreSQL, SQL Server, and anything else. When a pitfall has both a count and a timing, trust the count.
“Allocation” means managed memory, not bytes on a wire. It is what MemoryDiagnoser reports for your process: not RSS, not the database server’s memory, and not network traffic. It is the figure you can act on from application code, which is why I report it, but it is not interchangeable with “data transferred”. Where I do report column data, as in Pitfall 3, it is the summed length of the column values the query touches, measured in the database, and it is labelled as such.
Every database here is fully resident in RAM. The largest is 113 MB on a machine with 46 GiB, so the OS page cache holds all of it and no benchmark ever waits for a disk. On a server whose working set exceeds its buffer pool, the “before” cases get considerably worse and the “after” cases mostly do not. Another reason the ratios are floors.
One desktop, four runs. The machine was running a normal desktop session, not an isolated benchmarking rig. These numbers establish orders of magnitude and directions. They are not portable constants, and you should not quote them as though they were.
If you want to check any of this, the harness, the raw BenchmarkDotNet output, and a claim-by-claim comparison are in the benchmark repository. Running it takes one command and about four minutes.
Conclusion
Entity Framework Core is powerful, but it requires understanding what is happening under the hood. These six pitfalls account for the large majority of the EF performance problems I see:
- N+1 queries: use
IncludeandThenInclude, and be suspicious of lazy loading - Tracking overhead: use
AsNoTrackingfor read-only queries - Loading too much data: use
Selectprojection - Repeated query translation: use compiled queries on hot paths
- Missing indexes: index foreign keys and frequently filtered columns
- Cartesian explosion: use
AsSplitQuerywhen including multiple collections
Fixing them is mostly a matter of adding one method call in the right place, which is what makes them worth knowing.
The broader lesson, though, is the one the measurements taught me. Four of these six pitfalls turned out to be less dramatic in-process than their reputations suggest, and the two that stayed dramatic, the missing index and the cartesian explosion, stayed dramatic for reasons you can see in a query plan rather than a stopwatch. So look at the generated SQL, count the statements and the rows, and measure your own system on your own hardware. Those counts will tell you more than anybody’s multiplier, mine included.