Saving Changes

The change tracker, what SaveChangesAsync sends in one transaction, and how a concurrency token stops a lost update.

Syntaxint rows = await context.SaveChangesAsync();

You do not write UPDATE statements with EF Core. You change objects, then call SaveChangesAsync(), and the change tracker works out the SQL.

How the tracker sees an object

Every tracked entity has a state: Added, Modified, Deleted, Unchanged or Detached. Add sets Added. Changing a property of a tracked object sets Modified by itself, with no method call. Remove sets Deleted. SaveChangesAsync turns those states into INSERT, UPDATE and DELETE statements, wraps them in one transaction so they all happen or none do, returns the number of rows written, and resets everything to Unchanged.

One SaveChangesAsync is already transactional. Start an explicit transaction with Database.BeginTransactionAsync() only when two saves, or a save plus raw SQL, must succeed together.

Two people, one row

Read a row, change it, save it. If somebody else changed it in between, your UPDATE silently overwrites their work. That is a lost update. A concurrency token prevents it: mark a property as the token, and EF Core adds it to the WHERE clause. If the row no longer has the value you read, zero rows match, and EF Core throws DbUpdateConcurrencyException. This is optimistic concurrency: no locks, detect the clash at save time. Handle it with ex.Entries, decide which values win, and retry.

What trips people up

Update(entity) on an untracked object marks every property modified, so the UPDATE sets columns you never touched.

Example

Example · csharp
// dotnet add package Microsoft.EntityFrameworkCore.Sqlite
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();

using var context = new ShopContext(
    new DbContextOptionsBuilder<ShopContext>().UseSqlite(connection).Options);
await context.Database.EnsureCreatedAsync();

var mug = new Product { Name = "Mug", PriceCents = 900 };
context.Products.Add(mug);
Console.WriteLine($"before save: {context.Entry(mug).State}");

int written = await context.SaveChangesAsync();
Console.WriteLine($"rows written: {written}, state now: {context.Entry(mug).State}");

// No method call here. Changing the property is what marks the entity Modified.
mug.PriceCents = 1100;
Console.WriteLine($"after edit: {context.Entry(mug).State}");
await context.SaveChangesAsync();

Product reloaded = await context.Products.AsNoTracking().SingleAsync();
Console.WriteLine($"price in database: {reloaded.PriceCents}");

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public int PriceCents { get; set; }
}

public class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
}

// Output:
// before save: Added
// rows written: 1, state now: Unchanged
// after edit: Modified
// price in database: 1100

When to use it

  • Insert an order and its lines so that either both reach the database or neither does.
  • Stop two admin screens silently overwriting each other's edits to the same product.
  • Expire thousands of rows with one UPDATE instead of loading them all into memory.

More examples

A concurrency token and what to do on a conflict

Add a token to any row two users or two replicas can edit at once: prices, stock counts, order status. The catch block is where the product decision lives, so choose deliberately between last-write-wins, refusing the edit, and merging field by field.

Example · csharp
using Microsoft.EntityFrameworkCore;

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public int PriceCents { get; set; }

    // Application-managed token. Works on every provider, including SQLite,
    // which has no automatic version column. On SQL Server you can instead use
    // a byte[] property with [Timestamp], or Property(p => p.Version).IsRowVersion(),
    // and the database maintains it for you.
    public Guid Version { get; set; }
}

// modelBuilder.Entity<Product>().Property(p => p.Version).IsConcurrencyToken();

Product product = await context.Products.SingleAsync(p => p.Id == id);
product.PriceCents = 1250;
product.Version = Guid.NewGuid();      // a new value on every save, by hand

try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // Zero rows matched, so somebody changed the row after we read it.
    foreach (var entry in ex.Entries)
    {
        var databaseValues = await entry.GetDatabaseValuesAsync();
        if (databaseValues is null)
        {
            // The row was deleted. Nothing to merge.
            continue;
        }

        // Decide which values win, then accept the current row as the new
        // baseline so the retry is not rejected for the same reason.
        entry.OriginalValues.SetValues(databaseValues);
    }

    await context.SaveChangesAsync();
}

One transaction around two saves

Use an explicit transaction when a later step needs a key generated by an earlier one and the whole group must be atomic. Keep it short: the rows stay locked for its lifetime, so never wait for a user or an HTTP call inside one.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

// A single SaveChangesAsync is already all-or-nothing, so this is only needed
// when several operations must commit together.
await using IDbContextTransaction transaction =
    await context.Database.BeginTransactionAsync();

try
{
    var order = new Order { CustomerId = customerId, TotalCents = 4200 };
    context.Orders.Add(order);
    await context.SaveChangesAsync();          // order.Id is filled in here

    context.AuditEntries.Add(new AuditEntry { OrderId = order.Id, Action = "created" });
    await context.SaveChangesAsync();

    // Raw SQL inside the same transaction, because it uses the same connection.
    await context.Database.ExecuteSqlAsync(
        $"UPDATE customers SET order_count = order_count + 1 WHERE id = {customerId}");

    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Updating many rows without loading them

Use these for bulk work such as a price change across a category, where loading ten thousand entities to change one column each would be absurd. Accept the trade: no tokens, no interceptors, and stale tracked objects afterwards.

Example · csharp
using Microsoft.EntityFrameworkCore;

// ExecuteUpdateAsync (EF Core 7 and later) sends one UPDATE statement and
// loads nothing. It returns the number of rows affected.
bool renaming = true;

int updated = await context.Products
    .Where(p => p.CategoryId == 7)
    .ExecuteUpdateAsync(setters =>
    {
        // EF Core 10 accepts a plain lambda here, so ordinary C# such as an if
        // statement works. Earlier versions required an expression tree, which
        // had to be assembled by hand to do this.
        setters.SetProperty(p => p.PriceCents, p => p.PriceCents + 100);
        if (renaming)
        {
            setters.SetProperty(p => p.Name, "Clearance");
        }
    });

int removed = await context.Products
    .Where(p => p.Discontinued)
    .ExecuteDeleteAsync();

Console.WriteLine($"{updated} updated, {removed} deleted");

// Neither call goes through the change tracker: no SaveChangesAsync, no
// concurrency tokens, and objects already in memory are now out of date.
context.ChangeTracker.Clear();

Discussion

  • Be the first to comment on this lesson.