SaveChangesAsync

Know what SaveChangesAsync does step by step, which exceptions it throws and how to map them to HTTP responses, and how to stamp audit fields.

Signature
public virtual Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
public virtual Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
  • Added in .NET EF Core 1.0

Parameters

ParameterTypeDescription
$acceptAllChangesOnSuccessoptionalbool= trueWhen true, entities are marked Unchanged (and Deleted ones detached) after a successful save. Pass false when an outer retry may run the save again, then call ChangeTracker.AcceptAllChanges() after the commit.
$cancellationTokenoptionalCancellationToken= defaultCancels the database commands. Cancelling after the commit was sent does not undo it.

Return value

Task<int>The number of state entries written to the database: inserted, updated and deleted entities, plus join rows of skip navigations.

Errors

DbUpdateConcurrencyException when a concurrency token or the expected row count did not match; DbUpdateException for constraint violations and other database errors, with the provider exception (such as PostgresException) as InnerException; OperationCanceledException when cancelled; InvalidOperationException for tracking or model problems found before any SQL runs.

Changelog

  • PHP EF Core 5.0SavingChanges, SavedChanges and SaveChangesFailed events on DbContext; SaveChangesInterceptor.
  • PHP EF Core 7.0A save that needs only one statement is no longer wrapped in an explicit transaction, saving round trips.

SaveChangesAsync writes everything the change tracker knows about. The steps, in order:

  1. SavingChanges event and SaveChangesInterceptor.SavingChangesAsync run; this is where audit stamping usually lives. Audit code that reads ChangeTracker.Entries() still sees up-to-date states, because Entries() runs DetectChanges itself.
  2. DetectChanges (when AutoDetectChangesEnabled). Compares every tracked entity with its snapshot to find modified properties. Cost grows with the number of tracked entities, not the number changed.
  3. Ordering. Commands are sorted so principals are inserted before dependents and dependents deleted before principals.
  4. Transaction and batching. If no transaction is active and more than one statement is needed, EF Core begins one. Statements are sent in batches rather than one round trip each.
  5. Propagation. Generated keys, defaults and concurrency tokens are read back (RETURNING on PostgreSQL) and assigned to entities and foreign keys.
  6. Accept. On success the transaction commits and entities become Unchanged; Deleted ones are detached. The return value is the number of entries written.

If anything fails, the transaction rolls back, nothing is saved, and the tracker keeps the pending states, so the same context can retry or inspect what failed.

Exceptions and the HTTP response

ExceptionTypical causeResponse
DbUpdateConcurrencyExceptionRow version changed, or the row was deleted, since it was read409, or 412 with If-Match
DbUpdateException with PostgresException SqlState 23505Unique index violated (duplicate SKU, repeated idempotency key)409
DbUpdateException with SqlState 23503Foreign key violated409 or 422
OperationCanceledExceptionClient went awayNone; let it propagate

Catch narrowly with when filters on the inner exception's SQL state, and let everything else reach the exception handler. Checking for duplicates with a query first is not enough under concurrency: two requests can both pass the check, so the unique index plus this catch is the real guarantee.

Decisions a team makes

  • Call it once per unit of work. Saving inside a loop turns one batched transaction into N transactions and N round trips, and a failure halfway leaves partial data.
  • Cross-cutting fields (CreatedAt, UpdatedAt, CreatedBy) belong in a SaveChangesInterceptor or an override of SaveChangesAsync, not in every endpoint. Remember they do not run for ExecuteUpdateAsync.
  • Retries. With a retrying execution strategy and an explicit transaction, call SaveChangesAsync(acceptAllChangesOnSuccess: false) inside the strategy and AcceptAllChanges() after commit, so a replay still sees the pending changes.

Production failure modes

A return value of 0 on a write endpoint usually means the entity was never tracked (loaded with AsNoTracking) or nothing really changed. A request that saves 10,000 tracked rows spends most of its time in DetectChanges; split the work or use ExecuteUpdateAsync.

Example

Example · csharp
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
var app = builder.Build();

app.MapPost("/orders", async (PlaceOrder input, ShopDb db, CancellationToken ct) =>
{
    var skus = input.Lines.Select(l => l.Sku).ToList();
    var products = await db.Products.Where(p => skus.Contains(p.Sku)).ToDictionaryAsync(p => p.Sku, ct);

    var requestHash = Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(input)));
    var order = new Order { IdempotencyKey = input.IdempotencyKey, CustomerId = input.CustomerId, RequestHash = requestHash };
    foreach (var line in input.Lines)
    {
        if (!products.TryGetValue(line.Sku, out var product) || product.StockOnHand < line.Quantity)
            return Results.Problem(statusCode: 422, title: $"Not enough stock for {line.Sku}.");

        product.StockOnHand -= line.Quantity;   // tracked: becomes an UPDATE checked against xmin
        order.Lines.Add(new OrderLine { ProductId = product.Id, Quantity = line.Quantity, UnitPrice = product.Price });
    }
    db.Orders.Add(order);

    try
    {
        // One transaction: INSERT order, INSERT lines (with the new order id), UPDATE products.
        await db.SaveChangesAsync(ct);
    }
    catch (DbUpdateConcurrencyException)
    {
        // Another order changed one of these products after we read it.
        return Results.Problem(statusCode: 409, title: "Stock changed while placing the order. Retry.");
    }
    catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
    {
        // The unique index on (CustomerId, IdempotencyKey): this customer already sent this key.
        var existing = await db.Orders.AsNoTracking()
            .Where(o => o.CustomerId == input.CustomerId && o.IdempotencyKey == input.IdempotencyKey)
            .Select(o => new { o.Id, o.RequestHash })
            .SingleAsync(ct);
        return existing.RequestHash == requestHash
            ? Results.Ok(new { id = existing.Id, replayed = true })
            : Results.Problem(statusCode: 422, title: "This Idempotency-Key was already used with a different request.");
    }

    return Results.Created($"/orders/{order.Id}", new { id = order.Id });
});

app.Run();

public record PlaceOrderLine(string Sku, int Quantity);
public record PlaceOrder(Guid IdempotencyKey, int CustomerId, List<PlaceOrderLine> Lines);

public class Product
{
    public int Id { get; set; }
    public string Sku { get; set; } = "";
    public decimal Price { get; set; }
    public int StockOnHand { get; set; }
    public uint Version { get; set; }   // mapped to PostgreSQL xmin
}

public class Order
{
    public int Id { get; set; }
    public Guid IdempotencyKey { get; set; }
    public int CustomerId { get; set; }
    public string RequestHash { get; set; } = "";   // SHA-256 of the request body, to detect a reused key
    public List<OrderLine> Lines { get; } = [];
}

public class OrderLine
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().Property(p => p.Version).IsRowVersion();
        modelBuilder.Entity<Order>().HasIndex(o => new { o.CustomerId, o.IdempotencyKey }).IsUnique();
    }
}

When to use it

  • Placing an order that inserts the order and its lines and decrements stock in one transaction with a single SaveChangesAsync
  • Returning 409 instead of 500 when a unique index on IdempotencyKey rejects a replayed POST /orders
  • Stamping CreatedAt and UpdatedAt on every audited entity in one SaveChangesAsync override instead of in each endpoint

Frequently asked questions

Is SaveChangesAsync transactional?

Yes. All changes in one call are written atomically: EF Core starts a transaction when more than one statement is needed (a single statement is atomic by itself), and joins your transaction if one is already open.

Why does SaveChangesAsync return 0?

Nothing tracked was Added, Modified or Deleted. The usual causes are an entity loaded with AsNoTracking, a property set to the value it already had, or changes made through a different context instance.

How do I tell a duplicate key from other database errors?

Catch DbUpdateException and inspect InnerException: on PostgreSQL a PostgresException with SqlState 23505 (PostgresErrorCodes.UniqueViolation); on SQL Server a SqlException with number 2601 or 2627. Use an exception filter so other errors are not swallowed.

More examples

Audit fields in a SaveChangesAsync override

Override the overload that takes <code>acceptAllChangesOnSuccess</code>: the one-argument <code>SaveChangesAsync(ct)</code> calls it, so both paths are covered. <code>ChangeTracker.Entries</code> runs DetectChanges first, so modified entities already show as Modified. A <code>SaveChangesInterceptor</code> does the same job and can be shared between contexts.

Example · csharp
using Microsoft.EntityFrameworkCore;

public interface IAudited
{
    DateTimeOffset CreatedAt { get; set; }
    DateTimeOffset UpdatedAt { get; set; }
}

public class Invoice : IAudited
{
    public int Id { get; set; }
    public decimal Amount { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

public class BillingDb(DbContextOptions<BillingDb> options, TimeProvider clock) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();

    public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        var now = clock.GetUtcNow();
        foreach (var entry in ChangeTracker.Entries<IAudited>())
        {
            if (entry.State == EntityState.Added)
                entry.Entity.CreatedAt = now;
            if (entry.State is EntityState.Added or EntityState.Modified)
                entry.Entity.UpdatedAt = now;
        }
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }
}

Pitfall: SaveChanges inside a loop

The single save sends batched UPDATE statements inside one transaction, and the returned count tells you how many prices actually changed. For imports of hundreds of thousands of rows, process in chunks with a new context per chunk so the tracker stays small.

Example · csharp
using Microsoft.EntityFrameworkCore;

public record PriceRow(string Sku, decimal Price);

public sealed class PriceImport(CatalogDb db)
{
    // 5,000 rows: 5,000 transactions and round trips; a failure at row 3,000 leaves a half-applied import.
    public async Task ImportSlow(IReadOnlyList<PriceRow> rows, CancellationToken ct)
    {
        foreach (var row in rows)
        {
            var product = await db.Products.SingleAsync(p => p.Sku == row.Sku, ct);
            product.Price = row.Price;
            await db.SaveChangesAsync(ct);
        }
    }

    // One query, one SaveChanges: one transaction, batched UPDATEs, all or nothing.
    public async Task<int> Import(IReadOnlyList<PriceRow> rows, CancellationToken ct)
    {
        var bySku = rows.ToDictionary(r => r.Sku, r => r.Price);
        var skus = bySku.Keys.ToList();
        var products = await db.Products.Where(p => skus.Contains(p.Sku)).ToListAsync(ct);
        foreach (var product in products)
            product.Price = bySku[product.Sku];
        return await db.SaveChangesAsync(ct);   // only rows whose price really changed are written
    }
}

public class Product
{
    public int Id { get; set; }
    public string Sku { get; set; } = "";
    public decimal Price { get; set; }
}

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

Retry-safe save inside an execution strategy

<code>acceptAllChangesOnSuccess: false</code> protects the tracker: with the default <code>true</code>, an attempt that saved but failed to commit would already have marked everything Unchanged, and the retry would save nothing. It does not protect against a commit whose outcome is unknown; that is what <code>verifySucceeded</code> and the unique index on <code>ProviderPaymentId</code> are for, so a replay can never record the money twice.

Example · csharp
using Microsoft.EntityFrameworkCore;

public sealed class PaymentRecorder(ShopDb db)
{
    public async Task Record(int orderId, string providerPaymentId, decimal amount, CancellationToken ct)
    {
        var order = await db.Orders.SingleAsync(o => o.Id == orderId, ct);
        order.Status = "Paid";
        db.Payments.Add(new Payment { OrderId = orderId, ProviderPaymentId = providerPaymentId, Amount = amount });

        var strategy = db.Database.CreateExecutionStrategy();
        await strategy.ExecuteInTransactionAsync(
            // false: if the commit fails and the strategy retries, the changes are still pending.
            operation: token => db.SaveChangesAsync(acceptAllChangesOnSuccess: false, token),
            // A commit can reach the server and lose only its acknowledgement. Before retrying,
            // check whether it really committed, so the payment is never recorded twice.
            verifySucceeded: token => db.Payments.AsNoTracking()
                .AnyAsync(p => p.ProviderPaymentId == providerPaymentId, token),
            cancellationToken: ct);

        db.ChangeTracker.AcceptAllChanges();   // only after a confirmed commit
    }
}

public class Order
{
    public int Id { get; set; }
    public string Status { get; set; } = "";
}

public class Payment
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public string ProviderPaymentId { get; set; } = "";
    public decimal Amount { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Payment> Payments => Set<Payment>();

    // The last line of defence: a second row for the same provider payment cannot exist.
    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Payment>().HasIndex(p => p.ProviderPaymentId).IsUnique();
}

Discussion

  • Be the first to comment on this lesson.