Entity Framework Core is one of the most popular ORMs in the .NET ecosystem — for good reason. It abstracts away the complexity of working with databases, allowing developers to focus on business logic rather than SQL queries. But here’s the catch:
The abstraction is only helpful until it gets in your way.
When things go wrong — like performance issues, unexpected queries, or subtle bugs — understanding how EF Core works under the hood becomes essential. Knowing what it tracks, when it queries, and how it translates LINQ into SQL can mean the difference between an app that flies and crawls.
In this post, we’ll examine common EF Core performance pitfalls, why they happen, and, most importantly, how to avoid them.
Avoid N+1 Problem
Let’s take a look at the following code:
foreach (var author in await context.Authors.ToListAsync())
{
foreach (var book in author.Books)
{
Console.WriteLine($"Author {author.Name}, Book: {book.Title}");
}
}
What’s happening here? With lazy loading, EF Core triggers a separate query each time the Books property is accessed in the loop. So, after loading all authors, it sends one query per author to load books — this is known as the N+1 problem and can seriously hurt performance.
If you need all the books, load them all in one query using the Include method.
var authors = await context.Authors
.Include(a => a.Books)
.ToListAsync();
Use Projections
In the example above, we need only the author’s name. We don’t use other properties. For read-only operations, project out only the properties you need.
var authors = await context.Authors
.Select(a => new { a.Name, a.Books })
.ToListAsync();
Use Async API
To build scalable applications, prefer asynchronous APIs over synchronous ones—like SaveChangesAsync instead of SaveChanges.
Synchronous calls block the thread during database I/O, leading to higher thread usage and more context switching, which can hurt performance under load.
// Don't use
context.SaveChanges();
// Use instead
await context.SaveChangesAsync(token);
Use AsNoTracking
For read-only queries, use the AsNoTracking method. It’ll optimize the read performance and memory usage as EF Core doesn’t have to track entity changes, which means less overhead.
var authors = await context.Authors
.Include(a => a.Books)
.AsNoTracking()
.ToListAsync();
Use AsSplitQuery
Take a look at the following LINQ.
var departments = await context.Departments
.Include(d => d.Employees)
.Include(d => d.Projects)
.ToListAsync();
EF Core generates the SQL query with LEFT JOINs when we include relational collections.

Since Employees and Projects are related collections of Departments at the same level, the relational database produces a cross product. This means that each row from Employees is joined by each row from Projects.
Having 10 Projects and 10 Employees for a given Department, the database returns 100 rows for each Department.
It’s called a Cartesian explosion. It refers to a situation where a query produces an unexpectedly large number of results due to unintended cartesian products (cross joins) between tables.
To avoid a Cartesian explosion, use the AsSplitQuery method.
var departments = await context.Departments
.Include(d => d.Employees)
.Include(d => d.Projects)
.AsSplitQuery()
.ToListAsync();
EF Core generates three separate queries. The first query selects Departments. The other two include Projects and Employees with INNER JOINs separately.

To learn more about splitting queries, read my blog post, Single vs. Split Query in Entity Framework.
Limit the Size of Data
By default, a query returns all rows that match its filters.
Limit the size of the data fetch. Use the Take method to limit the number of records you want to fetch.
You can also use the pagination. The following code shows the Offset pagination.
int position = 200;
int pageSize = 100;
var authors = await context.Authors
.Where(a => a.Name.StartsWith('O'))
.Skip(position)
.Take(pageSize)
.ToListAsync();
There is also Keyset pagination. For more information, see the documentation page for pagination.
Use Bulk Delete and Update
Use the ExecuteDeleteAsync and ExecuteUpdateAsync methods for batch operations.
await context.Products
.Where(p => p.StockQuantity == 0)
.ExecuteDeleteAsync();
await context.Products
.Where(p => p.Category == "iPhone")
.ExecuteUpdateAsync(
p => p.SetProperty(x => x.StockQuantity, 0));
But remember some limitations.
Entity Framework Core doesn’t create implicit transactions for these methods as SaveChanges does. So, if you have several bulk updates and want to perform them within one transaction, you must begin it explicitly.
For more details, see my post Efficient Bulk Updates in Entity Framework.
Use Raw SQL
var authors = await context.Authors
.FromSqlRaw("SELECT * FROM Authors WHERE Name LIKE {0}", "O%")
.ToListAsync();
To learn how you can diagnose what queries EF Core generates, see my post Logging and Diagnostics in Entity Framework.
Use Streaming
Buffering occurs when you call methods like ToListAsync() or ToArrayAsync( )—the entire result set is loaded into memory at once.
On the other hand, Streaming returns one row at a time without holding everything in memory.
Streaming uses a fixed amount of memory, whether your query returns 1 row or 10,000. Buffering, however, consumes more memory as the result grows.
Streaming can be a much more efficient option for large result sets or performance-sensitive scenarios.
// Use AsAsyncEnumerable to stream data
await foreach (var author in
context.Authors.Where(p => p.Name.StartsWith('O')).AsAsyncEnumerable())
{
}
Conclusion
All the above tips are related to client-side performance improvements. However, they could not help if the database was poorly designed.
For example, use Indexes to improve read performance.
Learn how to design relational databases better because EF Core cannot fix everything.