Top 10 Mistakes Developers Make in EF Core (and How to Fix Them)
The 10 most common EF Core mistakes that slow your app down — N+1 queries, missing AsNoTracking, ToList too early, lazy loading, and more — with simple fixes.
A grocery shopping story
Imagine your mother sends you to the market with a list. She wants rice, dal, onions, and oil.
You could walk to the market, buy the rice, walk all the way home, then walk back for the dal, walk home again, walk back for the onions, and so on. Four trips for four items. Your legs hurt, the day is gone, and the work was simple.
A smart shopper does it differently. You read the whole list first, go to the market once, put everything in one bag, and come home. One trip, same shopping.
Talking to a database is exactly like this. Each "trip" to the database is slow. The biggest EF Core mistakes almost all come down to the same habit: making many small trips when one good trip would do. Let us walk through the ten most common mistakes, why they hurt, and the simple fix for each.
Mistake 1: The N+1 query problem
This is the most famous one. You load a list, then loop over it and touch related data. EF Core quietly runs a fresh query for each item.
// BAD: 1 query for blogs, then 1 query per blog for its posts.
var blogs = await context.Blogs.ToListAsync();
foreach (var blog in blogs)
{
// Each line here fires a new SQL query!
Console.WriteLine($"{blog.Name} has {blog.Posts.Count} posts");
}If you have 100 blogs, that is 1 + 100 = 101 queries. That is the "N+1" name: one query for the list, plus N more for the children.
The fix is to ask for the related data in the same trip using Include, or better, project only the fields you need.
// GOOD: one trip brings blogs and their posts together.
var blogs = await context.Blogs
.Include(b => b.Posts)
.ToListAsync();
// EVEN BETTER for read-only: project to a small shape.
var summaries = await context.Blogs
.Select(b => new { b.Name, PostCount = b.Posts.Count })
.ToListAsync();Mistake 2: Forgetting AsNoTracking on read-only queries
By default, EF Core tracks every entity it loads. Tracking means it keeps a copy in memory so it can spot changes later when you call SaveChanges. That is helpful when you plan to edit and save. But for a list page or a report, you never save those rows. You are paying for a feature you do not use.
// BAD for a read-only list: tracking wastes memory and time.
var products = await context.Products
.Where(p => p.IsActive)
.ToListAsync();
// GOOD: tell EF Core you will not change these.
var products = await context.Products
.Where(p => p.IsActive)
.AsNoTracking()
.ToListAsync();On medium data sets, no-tracking reads are often noticeably faster and lighter. Use it whenever the data is just for showing.
| Situation | Use tracking? | Why |
|---|---|---|
| List page, report, dropdown | No (AsNoTracking) | Read-only, never saved back |
Load, edit, then SaveChanges | Yes (default) | EF Core must detect your changes |
| Bulk read of thousands of rows | No (AsNoTracking) | Less memory pressure |
| Small load you will immediately update | Yes (default) | You need change detection |
Mistake 3: Calling ToList too early
ToList and ToListAsync are the moment EF Core actually runs the SQL and pulls rows into your app's memory. Anything you do after that point happens in C#, not in the database.
// BAD: pulls ALL orders into memory, then filters in C#.
var orders = (await context.Orders.ToListAsync())
.Where(o => o.Total > 1000)
.OrderByDescending(o => o.Date)
.Take(10);
// GOOD: filter, sort, and page in SQL. Only 10 rows come back.
var orders = await context.Orders
.Where(o => o.Total > 1000)
.OrderByDescending(o => o.Date)
.Take(10)
.ToListAsync();The bad version might bring back a million rows just to keep ten. Always build the whole query — Where, OrderBy, Skip, Take — and call ToListAsync last.
Where ToList should live in your query
Steps
Where
filter in SQL
OrderBy
sort in SQL
Skip/Take
page in SQL
ToListAsync
run query last
Mistake 4: Leaving lazy loading on in web apps
Lazy loading is a feature where touching a navigation property automatically loads it from the database. It sounds convenient, but it is a sneaky way to create the N+1 problem from Mistake 1. A simple foreach loop can fire hundreds of hidden queries, and you will not see them in your code.
For web APIs and reports, it is usually safer to turn lazy loading off and load related data on purpose with Include or a projection. That way every database trip is one you decided to make.
If you do keep lazy loading, never let it run during serialization (turning entities into JSON), because the serializer walks every property and can trigger a flood of queries.
Mistake 5: Returning full entities instead of small projections
When an API only needs a name and a price, do not return the whole Product entity with twenty columns and three navigation properties. Project to exactly the shape you need.
// BAD: loads every column, plus tracked entities.
var list = await context.Products.ToListAsync();
// GOOD: ask SQL for only the two columns you show.
var list = await context.Products
.Select(p => new ProductListItem
{
Name = p.Name,
Price = p.Price
})
.ToListAsync();Projections are automatically no-tracking, pull less data over the wire, and make the SQL smaller. This single habit fixes a surprising number of slow endpoints.
Mistake 6: Cartesian explosion from multiple Includes
When you Include two or more collections in one query, EF Core may JOIN them all together. The database then pairs every child of one collection with every child of the other, and the row count multiplies.
A blog with 10 posts and 10 tags should return about 20 things. With two collection Include calls in one query, it can return 10 × 10 = 100 rows, repeating data again and again.
The fix is AsSplitQuery, which loads each collection in its own clean query.
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Tags)
.AsSplitQuery() // each collection gets its own SQL query
.ToListAsync();Split queries make a few round-trips instead of one giant duplicated result. Measure both ways, but split usually wins when you include more than one collection.
Mistake 7: Loading rows just to update or delete them
A classic slow pattern is loading entities into memory only to change one field and save, or to delete them. For bulk changes, that is a lot of wasted reading.
// BAD: loads every expired cart, then deletes one by one.
var expired = await context.Carts
.Where(c => c.ExpiresAt < DateTime.UtcNow)
.ToListAsync();
context.Carts.RemoveRange(expired);
await context.SaveChangesAsync();
// GOOD: one SQL statement, no loading.
await context.Carts
.Where(c => c.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync();ExecuteUpdateAsync and ExecuteDeleteAsync (EF Core 7 and later) run a single UPDATE or DELETE in the database without pulling rows into memory. For bulk work, they are far faster. Just remember they bypass the change tracker, so loaded entities in memory may become stale.
Mistake 8: No pagination on list endpoints
If your endpoint returns "all customers" or "all orders," it works fine with 50 rows in testing and falls over with 500,000 rows in production. Never return an unbounded list. Always page.
public async Task<List<Order>> GetPage(int page, int size)
{
return await context.Orders
.OrderByDescending(o => o.Date) // paging needs a stable order
.Skip((page - 1) * size)
.Take(size)
.AsNoTracking()
.ToListAsync();
}Note the OrderBy. Paging without a stable sort order can return the same row twice across pages or skip rows. For very large tables, keyset pagination (filtering by the last seen id) scales even better than Skip/Take.
Safe paging order of operations
Steps
OrderBy
stable sort
Skip
jump to page
Take
page size only
Run
no-track, async
Mistake 9: Missing indexes on filtered and joined columns
EF Core writes the SQL, but it cannot create the right database indexes for you by guessing your traffic. If you constantly filter Orders by CustomerId but there is no index on that column, the database scans the whole table every time.
A quick way to spot trouble is to log the SQL EF Core generates and check the slow ones in your database's query plan. Then add indexes where the data shows you need them, often with a Fluent API mapping.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasIndex(o => o.CustomerId);
// A composite index helps queries that filter on both columns.
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.Date });
}| Symptom | Likely cause | First thing to check |
|---|---|---|
| One endpoint is slow only with big data | Missing index | Index the filtered/joined columns |
| Many queries on one page load | N+1 / lazy loading | Add Include or project |
| High memory use on reads | Tracking on read-only | Add AsNoTracking |
| Query returns duplicate rows | Cartesian explosion | Add AsSplitQuery |
Mistake 10: Mismanaging the DbContext lifetime
DbContext is not thread-safe and is meant to be short-lived. Two common mistakes sit at opposite ends:
- Too long: keeping one
DbContextalive for the whole app, or sharing it across requests. Its change tracker fills up over time, memory grows, and parallel use causes crashes. - Too short or wrong scope: creating a brand new context for every tiny call, or sharing one context between parallel tasks running at the same time.
In ASP.NET Core, register it as scoped so each request gets its own fresh context, and let dependency injection dispose it for you.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));If you run many parallel operations, give each parallel branch its own context (often via IDbContextFactory<T>). Never share one context across Task.WhenAll.
How these mistakes connect
Notice a pattern. Mistakes 1, 4, and 6 are all about too many or too wasteful trips. Mistakes 2 and 5 are about carrying more data than you need. Mistakes 3, 7, and 8 are about doing work in the wrong place (in C# instead of SQL). And mistakes 9 and 10 are about the plumbing around your queries. Once you see these four buckets, fixing EF Core performance becomes a habit, not a guessing game.
The four buckets of EF Core mistakes
Steps
Too many trips
N+1, lazy, explosion
Too much data
tracking, full entities
Wrong place
early ToList, no paging
Plumbing
indexes, context lifetime
A simple checklist before you ship a query
Before you call any query "done," ask yourself these questions:
- Will I save these rows? If no, add
AsNoTracking. - Do I touch related data in a loop? If yes, add
Includeor project. - Did I call
ToListbefore filtering or paging? If yes, move it to the end. - Could this return thousands of rows? If yes, add paging.
- Do I
Includetwo collections? If yes, tryAsSplitQuery. - Am I filtering on a column with no index? If yes, add one.
These six questions catch almost every mistake on this page.
Quick recap
- N+1 queries happen when you touch related data in a loop. Use
Includeor a projection so it all comes back in one trip. - Forgetting
AsNoTrackingmakes read-only queries slower and heavier. Add it whenever you only display data. - Calling
ToListtoo early drags every row into memory. Build the full query first, then materialize last. - Lazy loading in web apps causes hidden N+1 queries. Prefer loading related data on purpose.
- Returning full entities wastes bandwidth. Project to small shapes with
Select. - Two collection
Includes can cause cartesian explosion. Reach forAsSplitQuery. - Loading rows just to delete or update is wasteful. Use
ExecuteDeleteAsyncandExecuteUpdateAsyncfor bulk work. - No pagination breaks under real data. Always
OrderBy, thenSkip/Take. - Missing indexes turn fast queries into table scans. Index your filtered and joined columns.
- DbContext is short-lived and not thread-safe. Register it scoped, and give parallel work its own context.
References and further reading
- Introduction to Performance — EF Core (Microsoft Learn)
- Tracking vs. No-Tracking Queries — EF Core (Microsoft Learn)
- Efficient Querying — EF Core (Microsoft Learn)
- Single vs. Split Queries — EF Core (Microsoft Learn)
- Client vs. Server Evaluation — EF Core (Microsoft Learn)
Related Posts
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Eager Loading of Child Entities in EF Core: A Beginner's Guide
Learn eager loading in EF Core with Include and ThenInclude. Load child entities in one query, avoid the N+1 problem, and use filtered Include with simple examples.
EF Core Query Optimization: From 30 Seconds to 30 Milliseconds
Learn EF Core query optimization step by step: fix N+1 queries, use projections, AsNoTracking, indexes, and compiled queries to turn a 30-second query into 30ms.
How to Increase EF Core Performance for Read Queries in .NET
Make EF Core read queries fast in .NET 10 with AsNoTracking, projections, split queries, pagination, indexes, and compiled queries. Simple words and real examples.
EF Core Bulk Data Retrieval: 5 Methods You Should Know
Learn 5 simple EF Core methods to read large datasets fast: AsNoTracking, projection, pagination, split queries, and streaming. With diagrams and clear code.
Why Every EF Core Developer Needs to Try Entity Framework Extensions
A friendly guide to Entity Framework Extensions: bulk insert, update, delete and merge for EF Core, with simple analogies, diagrams, tables and code examples.