ExecuteUpdateAsync

Update rows with one set-based UPDATE statement, build setters conditionally in EF Core 10, and handle what the change tracker no longer does for you.

Signature
public static Task<int> ExecuteUpdateAsync<TSource>(this IQueryable<TSource> source, Action<UpdateSettersBuilder<TSource>> setPropertyCalls, CancellationToken cancellationToken = default)
  • Added in .NET EF Core 7.0

Parameters

ParameterTypeDescription
$sourceIQueryable<TSource>The rows to update. Where clauses, joins through navigations and query filters all become the UPDATE's WHERE.
$setPropertyCallsAction<UpdateSettersBuilder<TSource>>Calls to SetProperty(property, value) or SetProperty(property, entity => expression). Each call adds one column to the SET clause. In EF Core 10 this is a regular lambda, so it can contain if statements.
$cancellationTokenoptionalCancellationToken= defaultCancels the command.

Return value

Task<int>The number of rows the database reports as updated.

Errors

The provider's DbException (for example PostgresException for a check or foreign key violation), not DbUpdateException, because SaveChanges is not involved. InvalidOperationException when the query cannot be translated to a single UPDATE.

Changelog

  • PHP EF Core 10.0The setters parameter is a regular (non-expression) lambda, so setters can be added conditionally with if statements instead of building expression trees.

ExecuteUpdateAsync translates a LINQ query plus a list of setters into one UPDATE ... SET ... WHERE ... statement and runs it immediately. No entities are loaded, nothing is tracked, and SaveChangesAsync is not involved. Raising the price of 40,000 products is one round trip instead of 40,000 materialised entities and a batched UPDATE per row.

Setters

Each SetProperty adds one column. The value is either a constant or captured variable (SetProperty(p => p.Discontinued, true), sent as a parameter) or an expression over the row (SetProperty(p => p.Price, p => p.Price * 1.05m), computed by the database). Since EF Core 10 the setters argument is an ordinary lambda, so a PATCH endpoint can write if (input.Name is not null) s.SetProperty(p => p.Name, input.Name); instead of composing expression trees. Calling it with no setters is an error, so make sure at least one runs.

What you give up compared with SaveChanges

SaveChanges doesExecuteUpdate does not
Checks concurrency tokens, throws DbUpdateConcurrencyExceptionPut the version in the WHERE yourself and check the returned row count
Runs SaveChangesInterceptors and SavingChangesAudit columns must be set explicitly (command interceptors still see the SQL)
Updates tracked entitiesEntities already tracked keep their old values
Joins your other changes in one transactionRuns in its own statement; use an explicit transaction to combine

The statement updates one table. Navigations can appear in the WHERE and in value expressions (p => p.Category.DefaultMarkup) but you cannot set properties of a related entity in the same call. Global query filters apply, so soft-deleted or other-tenant rows are excluded unless you call IgnoreQueryFilters.

When to choose it

Use it for set-based changes that need no per-entity logic: status transitions on many rows, counters (s.SetProperty(p => p.StockOnHand, p => p.StockOnHand - qty) is atomic in the database and avoids read-modify-write races), soft deletes, and single-column updates where loading the row would be the only reason for a SELECT. Keep load-modify-SaveChanges when domain rules on the entity must run, or when several entities change together.

Production failure modes

  • Stale tracked entity. The request loaded the product, then ran ExecuteUpdate on it, then returned the tracked instance: the response shows the old price. Worse, a later SaveChangesAsync on that instance can write the old value back.
  • Lost audit trail. An UpdatedAt interceptor never fires, so the column stops moving.
  • Unbounded WHERE. A missing filter updates the whole table in one statement, holding locks for its duration. Always assert the expected row count on single-row updates.

Example

Example · csharp
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CatalogDb>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Catalog")));
builder.Services.AddSingleton(TimeProvider.System);
var app = builder.Build();

// Optimistic concurrency without loading: the version is part of the WHERE.
app.MapPut("/products/{id:int}/price", async (
    int id, PriceChange input, CatalogDb db, TimeProvider clock, CancellationToken ct) =>
{
    var rows = await db.Products
        .Where(p => p.Id == id && p.Version == input.ExpectedVersion)
        .ExecuteUpdateAsync(s => s
            .SetProperty(p => p.Price, input.Price)
            .SetProperty(p => p.Version, p => p.Version + 1)
            .SetProperty(p => p.UpdatedAt, clock.GetUtcNow()), ct);

    if (rows == 1) return Results.NoContent();

    // Zero rows: either the product does not exist or someone else changed it first.
    // 409, not 412: the version arrives in the body, not in an If-Match header.
    var exists = await db.Products.AnyAsync(p => p.Id == id, ct);
    return exists
        ? Results.Problem(statusCode: StatusCodes.Status409Conflict,
            title: "The product was changed by another request. Reload and retry.")
        : Results.NotFound();
});

// Atomic stock reservation: no read-modify-write race between concurrent orders.
app.MapPost("/products/{id:int}/reserve", async (int id, Reservation input, CatalogDb db, CancellationToken ct) =>
{
    var rows = await db.Products
        .Where(p => p.Id == id && p.StockOnHand >= input.Quantity)
        .ExecuteUpdateAsync(s => s.SetProperty(p => p.StockOnHand, p => p.StockOnHand - input.Quantity), ct);

    return rows == 1 ? Results.NoContent() : Results.Conflict("Not enough stock.");
});

app.Run();

public record PriceChange(decimal Price, int ExpectedVersion);
public record Reservation(int Quantity);

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

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

When to use it

  • Raising every price in a category by 5 percent with one UPDATE instead of loading 40,000 products
  • Reserving stock atomically with WHERE StockOnHand >= @qty so two concurrent checkouts cannot oversell the last unit
  • Implementing PATCH /customers/{id} that writes only the columns the client sent, using EF Core 10 conditional setters

Frequently asked questions

Does ExecuteUpdateAsync check concurrency tokens?

No. Add the token to the Where (p.Version == expected) and treat 0 affected rows as a conflict or a missing row. On PostgreSQL with xmin you can compare xmin in the Where the same way.

Do SaveChanges interceptors run?

No, SaveChanges is never called. DbCommandInterceptor implementations still see the UPDATE because they work at the command level, but anything built on SavingChanges, such as audit stamping, must be repeated as an explicit SetProperty.

Is it inside the same transaction as my SaveChanges?

Only if you start one. On its own the UPDATE runs and commits immediately. Begin a transaction with Database.BeginTransactionAsync and both the bulk statement and SaveChanges use it.

More examples

EF Core 10: setters chosen with if statements

Before EF Core 10 the setters were an expression tree, so conditional setters meant hand-building expressions. The SET clause now contains exactly the columns the client sent, plus <code>UpdatedAt</code>, which also guarantees the call never has zero setters.

Example · csharp
using Microsoft.EntityFrameworkCore;

public record CustomerPatch(string? Email, string? Phone, bool? MarketingOptIn);

public sealed class CustomerPatcher(ShopDb db, TimeProvider clock)
{
    public async Task<bool> Apply(int id, CustomerPatch patch, CancellationToken ct)
    {
        var now = clock.GetUtcNow();
        var rows = await db.Customers
            .Where(c => c.Id == id)
            .ExecuteUpdateAsync(s =>
            {
                if (patch.Email is not null) s.SetProperty(c => c.Email, patch.Email);
                if (patch.Phone is not null) s.SetProperty(c => c.Phone, patch.Phone);
                if (patch.MarketingOptIn is bool optIn) s.SetProperty(c => c.MarketingOptIn, optIn);
                s.SetProperty(c => c.UpdatedAt, now);   // always at least one setter
            }, ct);
        return rows == 1;
    }
}

public class Customer
{
    public int Id { get; set; }
    public string Email { get; set; } = "";
    public string? Phone { get; set; }
    public bool MarketingOptIn { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
}

Pitfall: a tracked entity goes stale

The cleaner fix is not to mix the two styles on the same rows in one unit of work: either load and modify, or ExecuteUpdate and read back with a no-tracking query. <code>ReloadAsync</code> is the escape hatch when both are unavoidable.

Example · csharp
using Microsoft.EntityFrameworkCore;

public sealed class Restock(CatalogDb db)
{
    public async Task<int> AddStock(int productId, int quantity, CancellationToken ct)
    {
        var product = await db.Products.SingleAsync(p => p.Id == productId, ct);   // tracked, StockOnHand = 10

        await db.Products.Where(p => p.Id == productId)
            .ExecuteUpdateAsync(s => s.SetProperty(p => p.StockOnHand, p => p.StockOnHand + quantity), ct);

        // product.StockOnHand is still 10 here. Returning it would report the wrong value,
        // and a later SaveChanges after another edit could not be trusted either.
        await db.Entry(product).ReloadAsync(ct);   // re-reads the row: now 10 + quantity
        return product.StockOnHand;
    }
}

public class Product
{
    public int Id { get; set; }
    public int StockOnHand { get; set; }
}

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

Combining a bulk operation (ExecuteDelete) and SaveChanges atomically

Bulk operations join an ambient <code>BeginTransactionAsync</code> transaction like any other command. Wrapping it in the execution strategy lets a retrying provider replay the whole unit after a transient failure instead of throwing because a user transaction was open. The replay only works because of <code>acceptAllChangesOnSuccess: false</code>: with the default, a failed commit would leave the order marked Unchanged and the retry would silently insert nothing.

Example · csharp
using Microsoft.EntityFrameworkCore;

public sealed class Checkout(ShopDb db)
{
    public async Task Place(Order order, CancellationToken ct)
    {
        var strategy = db.Database.CreateExecutionStrategy();
        await strategy.ExecuteAsync(async () =>
        {
            await using var tx = await db.Database.BeginTransactionAsync(ct);

            // Releases the cart's reservation hold in bulk.
            await db.CartItems.Where(i => i.CartId == order.CartId)
                .ExecuteDeleteAsync(ct);

            db.Orders.Add(order);
            // false: if the commit fails, the order stays Added, so a replay inserts it again.
            await db.SaveChangesAsync(acceptAllChangesOnSuccess: false, ct);

            await tx.CommitAsync(ct);   // both statements commit or neither does
        });

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

public class Order
{
    public int Id { get; set; }
    public Guid CartId { get; set; }
    public decimal Total { get; set; }
}

public class CartItem
{
    public int Id { get; set; }
    public Guid CartId { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<CartItem> CartItems => Set<CartItem>();
}

Discussion

  • Be the first to comment on this lesson.