Entity Framework Core and performance
The EF Core library translates LINQ code to SQL. If you do not know what SQL is built, you can easily send dozens of extra queries or bring a whole table into memory. Learn where a query runs, what Tracking costs, and how to run a Migration safely.
Author: bezzad
The problem: the orders page is slow
In our online shop, the admin panel has one page: “the last 50 orders, with the customer name”. The tables are small, but the page takes about 2 seconds. The code looks simple and clean:
var orders = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Take(50)
.ToListAsync(ct);
return orders.Select(o => new OrderRow(o.Id, o.Total, o.Customer.Name));
The problem is not visible in the C# code. The problem is in the SQL that EF Core builds. So the first skill in EF Core is this: know what query each line of code sends to the database.
How does the EF Core library work?
The DbContext class really does three jobs:
- Translation. It turns LINQ code into SQL.
- Building objects. It turns the result rows into C# objects.
- Change Tracking. It keeps a copy of every object it reads. When saving, it compares the object with the copy and runs an UPDATE only for the changed columns.
One important point from this picture: as long as the query type is IQueryable, the work happens in the database. When you bring it into memory with ToListAsync or AsEnumerable, every later Where and Select runs in the app’s memory.
// Bad: every order comes into memory, then C# filters them
var bigOrders = db.Orders.AsEnumerable().Where(o => o.Total > 1_000_000).ToList();
// Good: the filter becomes a WHERE clause in SQL
var bigOrders2 = await db.Orders.Where(o => o.Total > 1_000_000).ToListAsync(ct);
To see the real SQL, turn on the EF Core log. In the development environment, this is enough:
builder.Services.AddDbContext<ShopDb>(o => o
.UseSqlServer(builder.Configuration.GetConnectionString("Shop"))
.LogTo(Console.WriteLine, LogLevel.Information));
The N+1 problem
Back to the orders page. In this project, Lazy Loading is on. This means EF Core reads the customer of each order at the moment the code first touches it. Step by step:
- The ToListAsync method sends one query: 50 orders, without customers.
- Then Select runs in memory and asks for the customer name of each order.
- Each time, Lazy Loading sends a separate query for that one customer.
- So there is 1 query for the orders and 50 queries for the customers. This is called N+1.
- Each query has one network round trip. 51 round trips, one after another, make the page slow.
The same problem also happens without Lazy Loading. It is enough for a developer to run one query for each order inside a foreach loop.
Solution: Include or Projection
With Include, the customer comes with a JOIN in the same first query. But for a page that only shows data, the better way is Projection. This means you write Select before ToListAsync:
public sealed record OrderRow(int Id, decimal Total, string CustomerName);
var rows = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Take(50)
.Select(o => new OrderRow(o.Id, o.Total, o.Customer.Name))
.ToListAsync(ct);
Why is Projection better?
- EF Core itself builds a JOIN from the Select.
- Only the three needed columns are read, not all columns of both tables.
- The OrderRow object is not an Entity. So the Change Tracker and Lazy Loading are not involved at all.
Press the buttons and see the number of queries:
The page shows the last 20 orders with the customer name. We assume each round trip to the database takes 5 milliseconds.
Change tracking and AsNoTracking
When you want to change something, Tracking is a big help. Read the object, change it, and save it:
var order = await db.Orders.FirstAsync(o => o.Id == orderId, ct);
order.ShippingAddress = newAddress;
await db.SaveChangesAsync(ct); // UPDATE only the changed column
But when you only read, these copies are a useless cost. For each row, they take memory and comparison time. So for a read-only query, add the AsNoTracking method, or write a Projection directly.
One more point: the SaveChangesAsync method saves all changes in one transaction. So for a simple save, do not open a transaction yourself. The DbContext is itself a Unit of Work.
Bulk change without reading
If you want to mark all old unpaid orders as “expired”, you do not need to read them all first. The ExecuteUpdateAsync method sends an UPDATE command directly:
await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Expired), ct);
This command does not go through the Change Tracker. This means that if one of these orders was already read in the same DbContext, the object in memory does not change.
Very large data: read it piece by piece
The shop manager wants an export of 2 million transactions. If you read all of them with ToListAsync, 2 million objects are in memory at the same time, and the Pod may die from lack of memory. Instead, read and write the rows one by one:
await foreach (var tx in db.Transactions.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
await writer.WriteRowAsync(tx, ct);
}
In this case, each row is read, written, and then leaves memory. AsNoTracking is also needed here. Otherwise the Change Tracker keeps all the objects, and the benefit of reading piece by piece is lost.
DbContext lifetime
- One DbContext per request. The AddDbContext method registers it as Scoped. This is correct.
- One DbContext is not safe for several threads. If you run two queries on one DbContext at the same time with Task.WhenAll, EF Core throws an InvalidOperationException error. For parallel work, create a separate DbContext for each job (for example, with IDbContextFactory).
- Do not make the DbContext class a Singleton. Its change tracker keeps growing, and different requests see each other’s data.
Changing the database schema without Downtime
Now we want to turn the customer’s FullName column into two columns, FirstName and LastName. The service has 4 Pods, and the Deploy is Rolling. This means that for a few minutes, the old and new code work together.
If you write a Migration that creates the new columns and removes the old column at the same moment:
- Three old Pods still read the FullName column and get a “column does not exist” error.
- If the new version has a bug, a Rollback is also not possible. Because the old code needs a column that no longer exists.
The solution is the Expand and Contract pattern. First add the new thing, work with both for a while, and at the end remove the old one.
A few rules for Migration in Production:
- Run the migration separately from the app start. If all 4 Pods call the Migrate method at startup, they may run at the same time. It is better to have a separate step in the Pipeline. With the command that builds a SQL script, or with a Migration Bundle, this is easy:
dotnet ef migrations script --idempotent -o migrate.sql
dotnet ef migrations bundle -o efbundle
- Fill old data in batches. One UPDATE on millions of rows locks the table for a long time.
- Read the Migration script before running it. Sometimes, for a rename, EF Core drops the column and creates it again, and the data is lost.
Do you need a Repository on top of EF Core?
The DbContext class is itself a Unit of Work, and each DbSet is like a Repository. So a generic Repository (with Add, Update and GetAll for all tables) is usually just an extra layer. Worse, it sometimes hides Include and Projection, and we get back to N+1.
But a specific Repository, for example for the order Aggregate, is useful in some places. For example, when repeated queries should live in one place, or when you want one point to add a cache (with the Decorator pattern).
Important rules
- Always look at the generated SQL. With the log, or with the ToQueryString method.
- For display, write a Projection. Read only the needed columns.
- For reading, use AsNoTracking. Tracking is needed only when you want to save.
- Write the filter before ToListAsync. So it runs in SQL, not in memory.
- Be careful with Lazy Loading. Many teams keep it off so that N+1 does not stay hidden.
- Migrations compatible with the previous version. Never remove a column in the same Deploy while the old code still reads it.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| Reading a relation inside a loop or with Lazy Loading | The N+1 problem and hundreds of queries | Projection or Include |
| Calling AsEnumerable or ToList before Where | The whole table comes into memory | Filter before the query runs |
| Tracking for a read-only page | More memory and time | AsNoTracking or Projection |
| Reading 2 million rows with ToListAsync | Out of memory and the Pod dies | Read piece by piece with AsAsyncEnumerable |
| One DbContext for several parallel jobs | An InvalidOperationException error | One DbContext for each job |
| Removing a column in the same Deploy | 500 errors and Rollback is impossible | The Expand and Contract pattern |
| Running Migrate at startup of all Pods | Runs at the same time and errors | A separate step in the Pipeline |
When to use EF Core?
Good fit
- Most business apps: create, edit and read data.
- Places where Change Tracking and Migration save the team time.
- Normal queries that stay readable with LINQ.
With care
- Very complex reports. Sometimes hand-written SQL (with the SqlQuery method or Dapper) is more readable and faster.
- Bulk work on millions of rows. Use ExecuteUpdateAsync or a database-specific tool.
- When the team never looks at the generated SQL.
Summary in six lines
- The EF Core library translates LINQ code to SQL. Always look at that SQL.
- Before ToListAsync, the query runs in the database. After it, in memory.
- The N+1 problem means one query for each row. Solve it with Projection or Include.
- For reading, use AsNoTracking. For very large data, read piece by piece.
- One DbContext per request. Do not share it between threads.
- Run migrations with the Expand and Contract pattern, and separately from the app start.