Skip to main content
SEMastery
Data Accessbeginner

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.

12 min readUpdated November 8, 2025

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.

Figure 1: Many small trips versus one planned trip. Most EF Core mistakes are really just extra trips to the database.

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();
Figure 2: N+1 makes one query per parent. Include or projection collapses it into a single round-trip.

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.

SituationUse tracking?Why
List page, report, dropdownNo (AsNoTracking)Read-only, never saved back
Load, edit, then SaveChangesYes (default)EF Core must detect your changes
Bulk read of thousands of rowsNo (AsNoTracking)Less memory pressure
Small load you will immediately updateYes (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

Where
OrderBy
Skip/Take
ToListAsync

Steps

1

Where

filter in SQL

2

OrderBy

sort in SQL

3

Skip/Take

page in SQL

4

ToListAsync

run query last

Push all the work into SQL, then materialize at the very end.

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.

Figure 3: Two collection Includes in one JOIN multiply rows instead of adding them.

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

OrderBy
Skip
Take
AsNoTracking → run

Steps

1

OrderBy

stable sort

2

Skip

jump to page

3

Take

page size only

4

Run

no-track, async

Order first, then page, then no-track, then run.

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 });
}
SymptomLikely causeFirst thing to check
One endpoint is slow only with big dataMissing indexIndex the filtered/joined columns
Many queries on one page loadN+1 / lazy loadingAdd Include or project
High memory use on readsTracking on read-onlyAdd AsNoTracking
Query returns duplicate rowsCartesian explosionAdd 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 DbContext alive 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.

Figure 4: One scoped DbContext per request, disposed at the end. Never shared across requests or parallel tasks.

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

Too many trips
Too much data
Wrong place
Plumbing

Steps

1

Too many trips

N+1, lazy, explosion

2

Too much data

tracking, full entities

3

Wrong place

early ToList, no paging

4

Plumbing

indexes, context lifetime

Group the ten mistakes and the fixes become obvious.

A simple checklist before you ship a query

Before you call any query "done," ask yourself these questions:

  1. Will I save these rows? If no, add AsNoTracking.
  2. Do I touch related data in a loop? If yes, add Include or project.
  3. Did I call ToList before filtering or paging? If yes, move it to the end.
  4. Could this return thousands of rows? If yes, add paging.
  5. Do I Include two collections? If yes, try AsSplitQuery.
  6. 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 Include or a projection so it all comes back in one trip.
  • Forgetting AsNoTracking makes read-only queries slower and heavier. Add it whenever you only display data.
  • Calling ToList too 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 for AsSplitQuery.
  • Loading rows just to delete or update is wasteful. Use ExecuteDeleteAsync and ExecuteUpdateAsync for bulk work.
  • No pagination breaks under real data. Always OrderBy, then Skip/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

Related Posts