When I first started using EF Core in real projects, performance was not something I worried about too much. Most examples online worked fine, the database was small, and the business requirements were still simple. I honestly thought performance tuning was something you only do later when the system becomes very big.
That assumption did not last long.
In one of my projects, the database was not even huge. A few hundreds rows per table, some joins, nothing extreme. But users started complaining that screens were slow, reports took too long to load, and sometimes the system felt like it was freezing. That was the moment I realized that EF Core performance problems usually come from how we write queries, not from EF Core itself.
This article is not meant to repeat official documentation. Instead, I want to share the lessons I learned while working with EF Core on systems that had to deal with growing data sets and real business pressure. Everything here is based on experience, mistakes, and fixes that actually worked.
Large Data Sets Change How You Should Think
When your tables are small, almost everything feels fast. You can load full entities, include multiple relationships, and still get acceptable performance. That is why many bad patterns survive during early development.
For example, this kind of query looks innocent when the table is small.
var orders = await _context.Orders.ToListAsync();I wrote code like this many times in my early days. It worked. Nobody complained. Then the Orders table grew, reporting requirements expanded, and suddenly this query became a problem. Loading everything into memory just to display a small subset of data is one of the fastest ways to kill performance.
The first big lesson I learned is that EF Core will not protect you from inefficient queries. It gives you flexibility, but it also assumes you understand what you are doing.
Filtering Early Makes a Huge Difference
One very common mistake I see, and something I personally did many times, is filtering data after loading it instead of before.
Here is an example of what not to do.
var orders = await _context.Orders
.Include(o => o.Customer)
.ToListAsync();
var paidOrders = orders.Where(o => o.Status == OrderStatus.Paid);This code loads all orders into memory first and only then filters them. When the table has thousands or hundreds of thousands of rows, this becomes expensive very quickly.
In one project, a dashboard page only needed to show paid transactions for the current day. But the original implementation loaded all transactions and filtered them in C#. The fix was simple but extremely effective.
var paidOrders = await _context.Orders
.Where(o => o.Status == OrderStatus.Paid)
.Include(o => o.Customer)
.ToListAsync();Now the database does the filtering, which is exactly what it is good at. Since then, I always make sure that filtering happens as early as possible in the query.
Include Is Convenient but Dangerous
Include is one of those EF Core features that feels great at first. You add a few Includes, everything works, and your entities are fully populated. The problem is that Includes can silently create very heavy SQL queries.
I remember working on a page where a single query had multiple Includes. Customer, order items, payments, addresses, and more. The query worked, but it was slow and consumed a lot of memory.
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.ShippingAddress)
.ToListAsync();When we reviewed the UI requirements, we realized that the page only needed a few fields. Order number, customer name, and total amount. Everything else was unnecessary.
This is where projection became one of my favorite tools.
Projection Instead of Loading Full Entities
Instead of loading full entities with Includes, you can project only the data you need. This reduces the amount of data transferred from the database and lowers memory usage.
var orders = await _context.Orders
.Where(o => o.Status == OrderStatus.Paid)
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
CustomerName = o.Customer.Name,
TotalAmount = o.TotalAmount
})
.ToListAsync();After switching to projections, query performance improved immediately. Pages loaded faster, and memory usage became more stable. Since then, I rarely return full entities to the UI, especially for list screens and reports.
Tracking vs No Tracking Queries
Tracking is useful when you plan to update data. EF Core keeps track of entity changes so it knows what to save. But for read only scenarios, tracking is unnecessary overhead.
In one reporting module I worked on, memory usage kept increasing over time. The queries were correct, but everything was tracked by default. Adding AsNoTracking fixed the issue.
var transactions = await _context.Transactions
.AsNoTracking()
.Where(t => t.CreatedDate >= startDate)
.ToListAsync();Now my default approach is simple. If the data is read only, I use AsNoTracking.
Pagination Is Not Optional
Another painful lesson was learning that loading thousands of records into a single page is never a good idea. Even if the database can handle it, the UI and the user experience will suffer.
At one point, a list page froze the browser because it tried to load too many records at once. Pagination solved the problem immediately.
var pageSize = 50;
var pageNumber = 1;
var orders = await _context.Orders
.OrderBy(o => o.CreatedDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();Since then, pagination has been a requirement, not an option.
Indexes Matter More Than EF Core Tricks
There was a time when I spent hours optimizing EF Core queries, only to discover that the database had no index on the filtered column. After adding the index, the performance issue disappeared.
You can define indexes using EF Core like this.
modelBuilder.Entity<Order>()
.HasIndex(o => o.CreatedDate);EF Core performance and database performance are tightly connected. You cannot ignore one and expect the other to save you.
Measure and Understand Before Optimizing
One mistake I made early on was optimizing blindly. Now I always look at the generated SQL and measure execution time before changing anything.
Enabling query logging helped me understand what EF Core was actually sending to the database.
optionsBuilder.LogTo(Console.WriteLine);Once you see the SQL, many performance issues become obvious
Final Thoughts
EF Core is powerful, but it does not automatically make your application fast. Large data sets expose design problems quickly, but the good news is that most issues can be fixed with better query design, not bigger servers.
The biggest improvements I saw came from filtering early, projecting only what is needed, using no tracking queries, applying pagination, and making sure the database is properly indexed.
All of these lessons came from real systems, real users, and real performance complaints. Hopefully, sharing them helps you avoid the same mistakes.
All code examples in this article are not taken from my actual production projects. They were created only to simplify and demonstrate the concepts discussed.






