An EF Core partial update is safe only when the application can prove which columns the operation owns. A common failure starts with an incomplete request body being bound to a full entity or broad DTO. Missing non-nullable values become defaults, stale form values survive from an earlier read, and a generic Update or CurrentValues.SetValues call copies them into a tracked entity. If those values differ from the current database row, they may enter the SQL SET list and overwrite work completed by another process. The request can return success, the SQL can be valid, and no exception needs to occur. Preventing this class of lost update requires a field-level command contract, targeted SQL, optimistic concurrency, and a test that inspects the generated update rather than trusting the C# surface.
Why an EF Core Partial Update Can Still Cause a Lost Update
Direct answer: SetValues marks differences against the tracked entity, but it first copies every matching value supplied by the DTO. A default or stale value for an unowned property is still a difference, so that property can be written back and erase a concurrent change.
Microsoft's EF Core documentation distinguishes several update shapes. Passing a detached entity to Update causes every property to be updated. Querying the current entity and assigning individual properties allows the change tracker to update only values that actually changed. CurrentValues.SetValues is convenient between those approaches, but convenience does not define authorization or field ownership.
Consider an editor that is intended to change only DisplayName. Its input type also contains ProtectedCounter because the application reused a persistence entity as an HTTP model. The client does not send the counter, so model binding creates the integer default of zero. Meanwhile, a background operation changes the database value from 10 to 11. The update handler queries the current row, receives 11, and then calls SetValues with the request object. EF Core now sees a change from 11 to 0 and correctly marks the counter as modified. The framework did exactly what the program requested, but the program expressed a broader write contract than the user interface owned.
A stale snapshot creates the same failure without a missing value. A form may have loaded the counter as 10, another process may have moved it to 11, and the old form may later submit 10 with an unrelated name change. A generic mapper cannot infer that the name belongs to the editor while the counter belongs to a scheduler, state machine, ledger, or monitoring task.
This is why lost updates are not limited to two writers changing the same property. Two operations can legitimately target different properties and still conflict when one operation rewrites the entire row from an old snapshot. A transaction around that broad update does not repair the ownership error. It only makes the incorrect write atomic.
The defect is also easy to miss in tests. A single-request test starts from a stable row, submits an edit, and confirms that the intended field changed. It never injects a concurrent modification between the read and write, and it rarely asserts which columns appeared in the SQL SET clause. The dangerous behavior remains invisible until an independent process changes an unowned field at the wrong time.
Define Update Contracts with Field Ownership and Allow Lists
Direct answer: create a command DTO for each use case, include only editable fields, load the current row from the write source, and assign only allow-listed properties. Any property not named by that operation must remain absent from both the request contract and the SQL SET list.
A persistence entity describes stored state. A command describes one permitted transition. Treating them as the same type makes every mapped scalar property look editable even when ownership is divided across multiple workflows. A safer editor uses a narrow input such as this:
This handler is an excerpt, not a standalone program. The application supplies db, the Settings entity, and EditResult. Validate input and row-level authorization, configure ConcurrencyStamp with IsConcurrencyToken(), and handle DbUpdateConcurrencyException at the calling boundary. The preliminary version comparison cannot close the race between loading and saving; the token predicate in the UPDATE provides the final check. Every writer whose changes should invalidate that version must also advance the token.
public sealed record EditSettingsCommand(
int Id,
string DisplayName,
bool IsEnabled,
int ConcurrencyStamp);
public async Task EditAsync(EditSettingsCommand command)
{
var entity = await db.Settings.SingleOrDefaultAsync(x => x.Id == command.Id);
if (entity is null) return EditResult.NotFound;
if (entity.ConcurrencyStamp != command.ConcurrencyStamp)
return EditResult.Conflict;
entity.DisplayName = command.DisplayName.Trim();
entity.IsEnabled = command.IsEnabled;
entity.ConcurrencyStamp++;
await db.SaveChangesAsync();
return EditResult.Success;
}
The important property of this code is not that it is short. It is that the write set is visible. A reviewer can see that the editor owns DisplayName and IsEnabled, while counters, synchronization identifiers, workflow status, and audit values remain outside the command.
An allow list also fails closed when the schema grows. If a new database column is added next month, an old editor does not automatically gain permission to change it. A deny list has the opposite maintenance characteristic: every new property is writable until somebody remembers to protect it. That is a dangerous default for generic mapping infrastructure.
Use separate commands when screens or actions own different fields. RenameSettingsCommand, ChangeVisibilityCommand, and RescheduleCommand may appear repetitive, but each type carries a precise operational contract for maintainers: which transition is occurring, which validation applies, and which columns can be written. The resulting handlers are easier to test because each has a stable write boundary.
If existing infrastructure cannot immediately remove SetValues, place an explicit property map in front of it or copy values from a dictionary constructed from an allow list. Marking unwanted properties as IsModified = false after a broad copy can be an interim containment, but it is still a deny-list strategy. It is easy for a newly added property to escape the reset. Prefer positive selection over cleanup after overposting.
Reading the current row from the write source matters as well. A read replica can be useful for lists and reports, but it is a poor baseline for a command that will immediately write. Replica lag can turn a fresh request into an old snapshot before any human delay occurs. The command path should either read from the authoritative write connection or use a single conditional update whose predicate is evaluated by the database at write time.
Choose Tracked Assignment, ExecuteUpdate, or Targeted SQL Deliberately
Direct answer: use tracked query-and-assign when validation or relationship logic needs current state, use ExecuteUpdate when one operation owns a known set of columns, and reserve parameterized provider-specific SQL for atomic conditions that cannot be expressed clearly through the EF API.
Tracked assignment is a strong default for ordinary administrative edits. The handler loads the current entity, checks business rules, assigns a small set of properties, and calls SaveChanges. EF Core tracks property-level changes, so the generated update should contain only the fields that were actually assigned and changed. This path also works naturally when the operation changes an object graph or records related events in the same unit of work.
ExecuteUpdate is useful when the application already knows the target row and exact columns. It bypasses materialization and the change tracker, translating SetProperty calls directly into an update. That can make field ownership especially clear:
This is also a handler excerpt using the command above. Zero affected rows can mean a version conflict or a missing row; this example deliberately reports both as Conflict.
var affected = await db.Settings
.Where(x => x.Id == command.Id &&
x.ConcurrencyStamp == command.ConcurrencyStamp)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.DisplayName, command.DisplayName.Trim())
.SetProperty(x => x.ConcurrencyStamp, x => x.ConcurrencyStamp + 1));
if (affected == 0)
{
return EditResult.Conflict;
}
However, ExecuteUpdate does not automatically provide the concurrency behavior of tracked SaveChanges. Microsoft's documentation states that it does not use the change tracker, executes immediately, and returns the affected-row count. The caller must place the expected token in the predicate and treat zero affected rows as a conflict. Multiple immediate operations also need an explicit transaction when they must succeed together.
Avoid mixing ExecuteUpdate with a stale tracked instance in the same context. The database row changes immediately, but the in-memory object is not refreshed. A later SaveChanges can send values based on the old tracked state and undo part of the direct update. Use a separate context, reload the entity, clear tracking when appropriate, or select one update style for the unit of work.
Targeted SQL is justified when the update depends on the database's current value and a provider-specific expression communicates the rule better. For example, an application may clear a lease only if the row remains in an eligible state at the moment of the write. The condition belongs in the same atomic statement, not in a read-then-decide sequence with a race window. Even then, use parameters, list columns explicitly, inspect the affected-row count, and test the command against the actual provider in an isolated database.
No option is universally best. The decision should follow the business boundary. If validation needs the whole row and related entities, tracked assignment is readable. If a command changes two known scalar columns, ExecuteUpdate can be precise. If a complex conditional transition must remain atomic, targeted SQL may be the clearest expression. In every case, the write set must be positive and reviewable.
Optimistic Concurrency Is a Second Guard, Not a Field Allow List
Direct answer: a concurrency token detects that the version used by the writer is stale, but it cannot decide whether the writer is allowed to change a property. Limit the SET list first, then use a token to reject stale changes within that legitimate write boundary.
EF Core optimistic concurrency adds the original token value to the update predicate. If another writer changes the row and advances the token, the old update matches zero rows. Tracked SaveChanges reports that outcome as DbUpdateConcurrencyException, allowing the application to return a conflict, reload data, or perform a deliberate merge.
On SQL Server, a rowversion column can protect the row with a database-generated value. SQLite does not provide an equivalent automatically updating type, so an application-managed integer, GUID, or other token can be configured with IsConcurrencyToken. Provider choice therefore affects token generation, but not the architectural requirement: the application must preserve the original token, include it in the write condition, and define what happens when the condition fails.
Field allow lists and concurrency tokens answer different questions:
- Field ownership: Which columns may this command modify?
- Version validity: Is the state on which the command was based still current?
- Atomicity: Must several writes commit or fail together?
- Evidence: Did the generated SQL match the intended boundary?
A token without an allow list still permits an overbroad request to overwrite unrelated fields when no concurrent change happens. An allow list without a token still allows two editors to overwrite the same legitimate field on a last-write-wins basis. A transaction without either control merely makes the broad or stale update atomic.
Conflict handling must also preserve semantics. Catching DbUpdateConcurrencyException and blindly retrying with the same values converts a useful warning into a determined overwrite. A safe retry reloads current data, reevaluates rules, reapplies only owned fields, and has a bounded policy. Interactive applications commonly return a conflict response and show the submitted and current values. Automated workers may retry after a fresh read when the transition remains valid.
Token granularity is a domain decision. A row-wide token is simple and prevents any stale form from saving after any column changed. That may cause benign conflicts when independent fields are frequently updated. A more targeted conditional update can allow independent changes while still protecting a critical transition. The correct design follows invariants rather than minimizing the number of exceptions.
Verification and Reproduction: Assert the Result and the SQL SET List
Direct answer: reproduce the bug with two isolated contexts, verify that broad value copying overwrites a concurrent field, then prove that allow-listed assignment and ExecuteUpdate omit that field. Finally, advance a token and confirm that a stale save is rejected.
The scheduled verification used .NET SDK 10.0.400, EF Core SQLite 10.0.12, and an in-memory SQLite database. It did not connect to a real application database and used only fictional rows. LogTo was filtered to RelationalEventId.CommandExecuted so the test could inspect the actual update command without enabling value-rich logging.
Complete Isolated Verification Fixture
In a new empty working directory, create verification/efcore_partial_update_concurrency_20261008 and save the two files below under the indicated names. Run the following commands from that working directory. Install .NET SDK 10.0.400 first; initial restore requires access to the NuGet package. No global.json pins the SDK in this fixture, so verify the selected version yourself. The test uses only an in-memory SQLite database, not an existing application database.
This is a deterministic, ordered simulation of different writers, not a multithreaded load or simultaneous-commit test. The first three scenarios finish the other context's modification before exercising the update contract; the final scenario retains an old version before another writer advances it. It does not measure parallel performance or test every integration prerequisite of the handler excerpts above.
efcore_partial_update_concurrency_20261008.csproj:
Exe
net10.0
enable
enable
Program.cs:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
var sqlLog = new List();
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder()
.UseSqlite(connection)
.EnableSensitiveDataLogging(false)
.LogTo(
message => sqlLog.Add(message),
new[] { RelationalEventId.CommandExecuted },
LogLevel.Information)
.Options;
await using (var setup = new LabDbContext(options))
{
await setup.Database.EnsureCreatedAsync();
}
await ResetAsync(options);
sqlLog.Clear();
await SetConcurrentValueAsync(options);
await using (var vulnerable = new LabDbContext(options))
{
var current = await vulnerable.Records.SingleAsync(x => x.Id == 1);
var request = new SettingsPatch
{
Id = 1,
DisplayName = "edited-by-request",
ProtectedCounter = 0,
ConcurrencyStamp = 2
};
vulnerable.Entry(current).CurrentValues.SetValues(request);
await vulnerable.SaveChangesAsync();
}
var vulnerableResult = await ReadAsync(options);
var vulnerableSql = LastUpdate(sqlLog);
Require(vulnerableResult.ProtectedCounter == 0,
$"PASS SetValues copied a stale/default scalar and overwrote the concurrent value: {vulnerableResult.ProtectedCounter}");
Require(vulnerableSql.Contains("\"ProtectedCounter\"", StringComparison.Ordinal),
"PASS vulnerable UPDATE includes ProtectedCounter in its SET list");
await ResetAsync(options);
sqlLog.Clear();
await SetConcurrentValueAsync(options);
await using (var allowlisted = new LabDbContext(options))
{
var current = await allowlisted.Records.SingleAsync(x => x.Id == 1);
current.DisplayName = "edited-by-allowlist";
await allowlisted.SaveChangesAsync();
}
var allowlistedResult = await ReadAsync(options);
var allowlistedSql = LastUpdate(sqlLog);
Require(allowlistedResult.ProtectedCounter == 11,
$"PASS allowlisted assignment preserves the concurrent value: {allowlistedResult.ProtectedCounter}");
Require(!SetClause(allowlistedSql).Contains("\"ProtectedCounter\"", StringComparison.Ordinal),
"PASS allowlisted UPDATE omits ProtectedCounter from its SET list");
await ResetAsync(options);
sqlLog.Clear();
await SetConcurrentValueAsync(options);
await using (var direct = new LabDbContext(options))
{
var affected = await direct.Records
.Where(x => x.Id == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.DisplayName, "edited-by-execute-update"));
Require(affected == 1, $"PASS ExecuteUpdate reports one affected row: {affected}");
}
var executeResult = await ReadAsync(options);
var executeSql = LastUpdate(sqlLog);
Require(executeResult.ProtectedCounter == 11,
$"PASS ExecuteUpdate preserves the unowned concurrent field: {executeResult.ProtectedCounter}");
Require(!SetClause(executeSql).Contains("\"ProtectedCounter\"", StringComparison.Ordinal),
"PASS ExecuteUpdate emits a targeted SET list");
await ResetAsync(options);
sqlLog.Clear();
await using var staleContext = new LabDbContext(options);
var stale = await staleContext.Records.SingleAsync(x => x.Id == 1);
await SetConcurrentValueAsync(options);
stale.DisplayName = "stale-editor";
var concurrencyDetected = false;
try
{
await staleContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
concurrencyDetected = true;
}
Require(concurrencyDetected,
"PASS concurrency token turns a stale write into DbUpdateConcurrencyException");
var concurrencySql = LastUpdate(sqlLog);
Require(concurrencySql.Contains("\"ConcurrencyStamp\"", StringComparison.Ordinal),
"PASS concurrency token appears in the UPDATE predicate");
Console.WriteLine("SUMMARY 9/9 checks passed on .NET 10.0 with EF Core 10.0.12 and SQLite in-memory");
static async Task ResetAsync(DbContextOptions options)
{
await using var db = new LabDbContext(options);
await db.Records.ExecuteDeleteAsync();
db.Records.Add(new SettingsRecord
{
Id = 1,
DisplayName = "baseline",
ProtectedCounter = 10,
ConcurrencyStamp = 1
});
await db.SaveChangesAsync();
}
static async Task SetConcurrentValueAsync(DbContextOptions options)
{
await using var db = new LabDbContext(options);
var record = await db.Records.SingleAsync(x => x.Id == 1);
record.ProtectedCounter = 11;
record.ConcurrencyStamp = 2;
await db.SaveChangesAsync();
}
static async Task ReadAsync(DbContextOptions options)
{
await using var db = new LabDbContext(options);
return await db.Records.AsNoTracking().SingleAsync(x => x.Id == 1);
}
static string LastUpdate(List logs)
{
var update = logs.LastOrDefault(message => message.Contains("UPDATE \"Records\"", StringComparison.Ordinal));
if (update is null)
{
throw new InvalidOperationException("No UPDATE statement was captured.");
}
return update;
}
static string SetClause(string sql)
{
var setIndex = sql.IndexOf("SET", StringComparison.OrdinalIgnoreCase);
var whereIndex = sql.IndexOf("WHERE", StringComparison.OrdinalIgnoreCase);
if (setIndex options) : DbContext(options)
{
public DbSet Records => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.ConcurrencyStamp).IsConcurrencyToken();
});
}
}
sealed class SettingsRecord
{
public int Id { get; set; }
public string DisplayName { get; set; } = "";
public int ProtectedCounter { get; set; }
public int ConcurrencyStamp { get; set; }
}
sealed class SettingsPatch
{
public int Id { get; set; }
public string DisplayName { get; set; } = "";
public int ProtectedCounter { get; set; }
public int ConcurrencyStamp { get; set; }
}
- Verify the selected SDK and run the fixture from the working directory described above:
dotnet --version
dotnet run --project verification/efcore_partial_update_concurrency_20261008/efcore_partial_update_concurrency_20261008.csproj --configuration Release
Inspect the vulnerable scenario. One context changes
ProtectedCounterfrom 10 to 11. A second handler loads the current row and applies a request whose counter is the default zero throughCurrentValues.SetValues. The expected evidence is a final counter of zero and an UPDATESETlist containingProtectedCounter. This test intentionally proves the failure mode in a sandbox; it is not a recommended update pattern.Inspect the allow-listed assignment. Repeat the concurrent change, but assign only
DisplayNameon the tracked entity. The counter must remain 11, and the capturedSETclause must not containProtectedCounter. A final value assertion alone is insufficient because an unchanged value could still be redundantly written and preserve a race window.Inspect
ExecuteUpdate. Use oneSetPropertyfor the owned name field and check that the operation reports one affected row. The counter must remain 11, and the SQLSETlist must remain targeted. When a token predicate is added, zero affected rows must be handled as a conflict rather than reported as success.Inspect optimistic concurrency. Load token version 1 in one context, change the row and advance the token to version 2 in another context, then save through the stale context. The expected result is
DbUpdateConcurrencyException, with the token visible in the generated update predicate.
The command executed successfully and reported SUMMARY 9/9 checks passed on .NET 10.0 with EF Core 10.0.12 and SQLite in-memory. The checks covered the reproducible overwrite, the vulnerable SET list, preservation through allow-listed assignment, preservation through ExecuteUpdate, affected-row reporting, and stale-write rejection through a concurrency token.
These results establish the semantics of the named sandbox, not every provider configuration. A real project should repeat the integration test using its database provider, mappings, converters, interceptors, and transaction policy. SQL shape can differ by provider, and provider-specific generated values need provider-specific verification.
Expected summary follows. The process exits 0 when all assertions hold; the expected concurrency exception is caught and counts as a passing test. Any failed assertion throws an unhandled exception and produces a nonzero exit code.
SUMMARY 9/9 checks passed on .NET 10.0 with EF Core 10.0.12 and SQLite in-memory
Code Review and Release Checklist for Safe Partial Updates
Direct answer: review the command DTO before the repository method, verify field ownership before concurrency behavior, and require tests that assert both database values and generated SQL. A successful HTTP response does not prove that unrelated columns were preserved.
Use the following gate before merging an update endpoint:
- Does the request type contain only fields owned by this use case, rather than reusing the persistence entity?
- Can omitted non-nullable numbers, booleans, or dates become meaningful defaults?
- Is current state loaded from the authoritative write source rather than a potentially delayed replica?
- Does the handler assign only allow-listed properties or name them through
SetProperty? - Will a newly added database column remain non-editable by old endpoints by default?
- Is the concurrency token generated, preserved, and compared correctly for the selected provider?
- Does the application expose a conflict instead of swallowing it or retrying indefinitely?
- If
ExecuteUpdateis used, is the affected-row count checked and is stale tracking avoided? - If the use case performs several writes, is the transaction boundary explicit?
- Does the test assert the actual
SETlist in addition to final values and status codes?
Operational signals should follow the same failure model. Useful measurements include conflict counts, zero-affected-row rates for conditional updates, conflict response rates, unusual version jumps, and invariant checks for values that should move together. These metrics do not guarantee correctness, but they make emerging contention and contract mistakes easier to locate.
The durable rule is simple: reading a complete entity does not grant permission to write a complete entity. EF Core can track changes and generate efficient commands, but it cannot infer ownership between a form, scheduler, state machine, synchronization process, and counter update. Once every command names its fields, every stale write has a token policy, and every test inspects the SQL boundary, partial updates become a verifiable consistency contract rather than an optimistic convention.