Change Tracking and Loading Data
What EF Core remembers, and when it goes back to the database
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
You load a customer with AsNoTracking, change its Name and call SaveChanges. What happens?
Real-world analogy
What is it?
Change tracking is how EF Core knows what to write back. When you load an entity through a normal query, the context keeps a copy of its original values and watches the entity. On SaveChanges it works out which entities were added, changed or deleted and sends the matching INSERT, UPDATE and DELETE statements. Tracking costs memory and time, so read-only queries should switch it off with AsNoTracking. How you load related data decides how many queries run.
Real-world relevance
The most common production slowdown is a list page that loads thousands of tracked entities it only displays, or a loop that triggers one query per row. The most common confusing bug is the opposite: a developer adds AsNoTracking to speed up a query, then edits the result and finds SaveChanges did nothing. Both come from not knowing whether the context is watching the object.
Key points
- The tracker keeps original values — When a query loads an entity, the context stores a snapshot next to it. On SaveChanges it compares the two.
- Five entity states — Added, Modified, Unchanged, Deleted and Detached. The state decides which SQL statement runs, if any.
- SaveChanges writes the differences — Change only Name and the UPDATE touches only that column. Nothing changed means no SQL at all.
- AsNoTracking for read-only queries — It skips the snapshot, so the query is faster and uses less memory. Use it when you will not save the results.
- Include is eager loading — Include(o => o.Items) fetches the related rows in the same query by default. One trip to the database brings back the whole graph.
- Explicit loading fetches on demand — Entry(order).Collection(o => o.Items).LoadAsync() loads related data later, only when you decide you need it.
- Lazy loading hides N+1 — It loads related data when you first touch a navigation property, so a loop quietly runs one query per row. It stays off unless you turn it on, usually with the proxies package.
- Project to DTOs when reading — A Select into a small DTO fetches only the columns you need. A projection of plain values is not tracked.
- One context, one thread — A DbContext is not thread-safe. Use one per request and do not share it across parallel tasks.
Quick check
Answer from memory — scrolling up first makes it stick less.
Which call fetches an order and its items in one query?
A loop reads order.Customer.Name for 500 orders and runs 501 queries. What is this called?
Code example
public async Task RenameAsync(ShopContext db, int id, string name, CancellationToken ct)
{
// Tracked: the context stores the original values
var customer = await db.Customers.FirstAsync(c => c.Id == id, ct);
customer.Name = name;
Console.WriteLine(db.Entry(customer).State); // Modified
// UPDATE Customers SET Name = @p0 WHERE Id = @p1
await db.SaveChangesAsync(ct);
}
public record CustomerRow(int Id, string Name, int OrderCount);
public Task<List<CustomerRow>> ListAsync(ShopContext db, CancellationToken ct)
{
// Read-only: no tracking, only three columns come back
return db.Customers
.AsNoTracking()
.Select(c => new CustomerRow(c.Id, c.Name, c.Orders.Count))
.ToListAsync(ct);
}Line-by-line walkthrough
- 1. FirstAsync loads one customer. Because the query is tracked, the context stores its original values.
- 2. Changing Name notifies nobody. Entry() runs change detection for this entity, finds the new value and reports Modified.
- 3. SaveChangesAsync compares original and current values and sends an UPDATE for the Name column only.
- 4. CustomerRow is a small record that holds just what the list page needs.
- 5. AsNoTracking switches the tracker off for this read-only query.
- 6. The Select turns the query into one SQL statement that returns three columns, including a count of orders.
Spot the bug
public async Task RenameAsync(ShopContext db, int id, string name)
{
var customer = await db.Customers
.AsNoTracking()
.FirstAsync(c => c.Id == id);
customer.Name = name;
await db.SaveChangesAsync(); // runs, but the database row is unchanged
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Change tracking in EF Core (Microsoft Learn)
- Tracking vs. no-tracking queries (Microsoft Learn)
- Loading related data (Microsoft Learn)
Lock it in