Change Tracking and SaveChanges

Know which entity states SaveChanges turns into INSERT, UPDATE and DELETE, and update detached data in an API safely.

Syntaxvar 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.

StateHow it gets thereSaveChanges sends
AddedAdd, or reachable from an added entityINSERT, then reads back generated keys
UnchangedTracking query, AttachNothing
ModifiedProperty changed, or UpdateUPDATE of modified columns only
DeletedRemoveDELETE
DetachedNever 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 SaveChangesAsync inside a loop of 1,000 items: 1,000 transactions and round-trips. Add everything, save once, or use ExecuteUpdateAsync for set-based changes.
  • A long import using one context: the tracker holds every row ever read and DetectChanges gets slower with each one. Save in chunks and call ChangeTracker.Clear().
  • Forgetting that AsNoTracking() results are detached: changing them and calling SaveChangesAsync saves nothing and returns 0.

Example

Example · csharp
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.

Example · csharp
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 =&gt; x.Phone).IsModified = true</code> to write just that column.

Example · sql
-- 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.

Example · csharp
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

  • Be the first to comment on this lesson.