Change Tracking and SaveChanges
Know which entity states SaveChanges turns into INSERT, UPDATE and DELETE, and update detached data in an API safely.
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return Results.NotFound();
order.ShippingNote = input.ShippingNote; // Unchanged -> Modified
await db.SaveChangesAsync(ct);Every entity a context knows about has an EntityState. A tracking query attaches results as Unchanged and stores a snapshot of their original values. When you change a property nothing happens immediately; SaveChangesAsync first calls DetectChanges, which compares every tracked entity with its snapshot, then generates SQL for each entity that is not Unchanged.
| State | How it gets there | SaveChanges sends |
|---|---|---|
Added | Add, or reachable from an added entity | INSERT, then reads back generated keys |
Unchanged | Tracking query, Attach | Nothing |
Modified | Property changed, or Update | UPDATE of modified columns only |
Deleted | Remove | DELETE |
Detached | Never tracked, or after ChangeTracker.Clear() | Nothing |
What SaveChanges does
All commands run in one transaction (unless you opened one yourself), ordered so that principals are inserted before dependents, and grouped into batches to save round-trips; SQL Server batches up to 42 statements by default. Generated values such as identity keys come back and are written into your objects, foreign keys of dependents are fixed up, and every entity returns to Unchanged. The return value is the number of rows affected. A failure rolls back the whole transaction and leaves the tracker as it was, so retrying the same context is possible but usually wrong; create a new scope.
The context also does identity resolution: one instance per key. Attaching a second object with a key the context already tracks throws The instance of entity type 'Customer' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked.
Detached entities in a web API
Each request gets a new context, so the object a client sends back in a PUT is detached. There are two ways to save it.
db.Update(entity) attaches the whole graph and marks every property Modified: the UPDATE writes every column, including ones the client never saw. A related entity whose key is unset is marked Added instead. It costs no read, but it overwrites a concurrent change to a column this request did not touch (a lost update), and binding the entity type straight from the request body lets a client set fields such as CreditLimit that it should not (overposting).
Load, apply, save: fetch the tracked entity, return 404 if it is missing, copy the allowed fields from a request DTO, and call SaveChangesAsync. The UPDATE contains only the columns whose values actually changed, nothing at all if none did. It costs one extra SELECT by primary key, which is almost always worth it. Choose it as the default; reach for Attach plus Property(...).IsModified = true only on a measured hot path.
Loading alone does not stop lost updates: a concurrency token is compared with the value this request just read, so it only covers the milliseconds between its SELECT and UPDATE. To cover the client's round trip, the client sends back the version it read (in the DTO or an If-Match ETag); set it as the token's OriginalValue before saving and map DbUpdateConcurrencyException to 409, as the main example does. The concurrency lesson goes deeper.
Failure modes
- Calling
SaveChangesAsyncinside a loop of 1,000 items: 1,000 transactions and round-trips. Add everything, save once, or useExecuteUpdateAsyncfor set-based changes. - A long import using one context: the tracker holds every row ever read and
DetectChangesgets slower with each one. Save in chunks and callChangeTracker.Clear(). - Forgetting that
AsNoTracking()results are detached: changing them and callingSaveChangesAsyncsaves nothing and returns 0.
Example
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
var app = builder.Build();
app.MapPost("/customers", async (CreateCustomer input, ShopDb db, CancellationToken ct) =>
{
var customer = new Customer { Name = input.Name, Email = input.Email, CreditLimit = 500m };
db.Customers.Add(customer); // Added
await db.SaveChangesAsync(ct); // INSERT ... RETURNING "Id"; customer.Id is now set
return Results.Created($"/customers/{customer.Id}", new { customer.Id });
});
// Load, apply, save: only changed columns are written, missing rows become 404,
// and CreditLimit cannot be set by the client because the DTO has no such field.
app.MapPut("/customers/{id:int}", async (int id, UpdateCustomer input, ShopDb db, CancellationToken ct) =>
{
var customer = await db.Customers.FindAsync([id], ct);
if (customer is null) return Results.NotFound();
// Compare against the version the client read, not the one just loaded,
// so an edit made by someone else since the client's GET is detected.
db.Entry(customer).Property(c => c.Version).OriginalValue = input.Version;
customer.Name = input.Name; // DetectChanges compares with the snapshot
customer.Email = input.Email;
customer.Phone = input.Phone;
try
{
var rows = await db.SaveChangesAsync(ct); // UPDATE ... WHERE "Id" = @id AND xmin = @version
return Results.Ok(new { id, updated = rows > 0, version = customer.Version });
}
catch (DbUpdateConcurrencyException)
{
return Results.Conflict(new { error = "The customer was changed by someone else. Reload and retry." });
}
});
app.MapDelete("/customers/{id:int}", async (int id, ShopDb db, CancellationToken ct) =>
{
var customer = await db.Customers.FindAsync([id], ct);
if (customer is null) return Results.NotFound();
db.Customers.Remove(customer); // Deleted
await db.SaveChangesAsync(ct); // DELETE FROM "Customers" WHERE "Id" = @p0
return Results.NoContent();
});
app.Run();
public sealed record CreateCustomer(string Name, string Email);
public sealed record UpdateCustomer(string Name, string Email, string? Phone, uint Version);
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
// On Npgsql a uint row version maps to PostgreSQL's xmin system column.
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Customer>().Property(c => c.Version).IsRowVersion();
}
public sealed class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Email { get; set; }
public string? Phone { get; set; }
public decimal CreditLimit { get; set; }
public uint Version { get; set; }
}When to use it
- Implement PUT /customers/{id} by loading the row, copying fields from a DTO and saving only the columns that changed
- Explain why a nightly import slowed to a crawl after 100,000 rows and fix it with chunked saves and ChangeTracker.Clear
- Stop a client from raising its own credit limit by replacing db.Update(bodyEntity) with an explicit DTO mapping
More examples
Watching states with DebugView
<code>ChangeTracker.DebugView.LongView</code> lists every tracked entity with its state and, for modified properties, the original value. The added product shows a large negative temporary key until <code>SaveChanges</code> replaces it with the generated one. Use the view in tests and in the debugger when a save writes more or less than you expected.
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<ShopDb>().UseSqlite(connection).Options;
using (var setup = new ShopDb(options))
{
setup.Database.EnsureCreated();
setup.Products.AddRange(
new Product { Sku = "KET-100", Name = "Blue kettle", Price = 39.90m },
new Product { Sku = "MUG-200", Name = "Stoneware mug", Price = 12.50m });
setup.SaveChanges();
}
using var db = new ShopDb(options);
var kettle = db.Products.Single(p => p.Sku == "KET-100"); // Unchanged
kettle.Price = 34.90m; // Modified after DetectChanges
db.Products.Remove(db.Products.Single(p => p.Sku == "MUG-200")); // Deleted
db.Products.Add(new Product { Sku = "TEA-300", Name = "Green tea", Price = 6.20m }); // Added
db.ChangeTracker.DetectChanges();
Console.WriteLine(db.ChangeTracker.DebugView.LongView);
// Product {Id: <temporary negative key>} Added ...
// Product {Id: 1} Modified ... Price: 34.90 Modified Originally 39.90
// Product {Id: 2} Deleted ...
Console.WriteLine($"Rows affected: {db.SaveChanges()}"); // 3, in one transaction
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
public sealed class Product
{
public int Id { get; set; }
public required string Sku { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
}Update() on a detached graph versus a tracked edit
The tracked edit costs one indexed <code>SELECT</code> and writes a single column, so it cannot clobber a concurrent change to <code>CreditLimit</code>. <code>Update</code> skips the read and rewrites the whole row. When the read is genuinely too expensive, use <code>Attach</code> and set <code>db.Entry(c).Property(x => x.Phone).IsModified = true</code> to write just that column.
-- db.Update(customerFromRequestBody); await db.SaveChangesAsync();
-- Every mapped column is written, including CreditLimit, which the client
-- should not control and which a back-office user changed a second ago.
UPDATE "Customers" SET "CreditLimit" = @p0, "Email" = @p1, "Name" = @p2, "Phone" = @p3
WHERE "Id" = @p4;
-- var c = await db.Customers.FindAsync([id]); c.Phone = input.Phone; await db.SaveChangesAsync();
SELECT c."Id", c."CreditLimit", c."Email", c."Name", c."Phone"
FROM "Customers" AS c
WHERE c."Id" = @p
LIMIT 1;
UPDATE "Customers" SET "Phone" = @p0
WHERE "Id" = @p1;Importing in chunks without a growing tracker
Saving once per row would cost a transaction and round-trip per row; saving once at the end would hold 200,000 tracked entities and make every <code>DetectChanges</code> scan all of them. Chunks of a few hundred keep memory flat. Each chunk commits separately, so a failure halfway leaves earlier chunks saved; design the import to be restartable.
using Microsoft.EntityFrameworkCore;
public sealed class PriceListImporter(ShopDb db, ILogger<PriceListImporter> logger)
{
public async Task ImportAsync(IAsyncEnumerable<PriceRow> rows, CancellationToken ct)
{
const int chunkSize = 500;
var pending = 0;
await foreach (var row in rows.WithCancellation(ct))
{
db.PriceEntries.Add(new PriceEntry { Sku = row.Sku, Price = row.Price, ValidFrom = row.ValidFrom });
if (++pending < chunkSize) continue;
await db.SaveChangesAsync(ct); // one transaction, statements batched
db.ChangeTracker.Clear(); // forget saved entities so DetectChanges stays cheap
pending = 0;
}
if (pending > 0) await db.SaveChangesAsync(ct);
logger.LogInformation("Price list imported");
}
}
public sealed record PriceRow(string Sku, decimal Price, DateOnly ValidFrom);
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<PriceEntry> PriceEntries => Set<PriceEntry>();
}
public sealed class PriceEntry
{
public long Id { get; set; }
public required string Sku { get; set; }
public decimal Price { get; set; }
public DateOnly ValidFrom { get; set; }
}
Discussion