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.
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
| Parameter | Type | Description |
|---|---|---|
$source | IQueryable<TSource> | The rows to update. Where clauses, joins through navigations and query filters all become the UPDATE's WHERE. |
$setPropertyCalls | Action<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. |
$cancellationTokenoptional | CancellationToken= default | Cancels 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 does | ExecuteUpdate does not |
|---|---|
Checks concurrency tokens, throws DbUpdateConcurrencyException | Put the version in the WHERE yourself and check the returned row count |
Runs SaveChangesInterceptors and SavingChanges | Audit columns must be set explicitly (command interceptors still see the SQL) |
| Updates tracked entities | Entities already tracked keep their old values |
| Joins your other changes in one transaction | Runs 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
SaveChangesAsyncon that instance can write the old value back. - Lost audit trail. An
UpdatedAtinterceptor 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
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.
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.
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.
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