DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s)
— EF Core saved a change to an entity it had loaded, and the database reported that no row
was affected. In the reproduction another connection had deleted the row between the load
and SaveChanges(). Catch the exception and decide what to do with each entry
in ex.Entries.
The fix shown here reads the current database values with
GetDatabaseValues(): a null result means the row is gone, so the
entry is detached and nothing is saved. Any other case is thrown again with
throw;, because only the deleted-row case was tested.
The error
PS> dotnet run -- conc-bad
...
Unhandled exception. Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyException(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected)
at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithRowsAffectedOnly(Int32 commandIndex, RelationalDataReader reader)
...
The first cut hides four NuGet audit warnings printed by dotnet run, the
second the rest of the EF Core stack trace.
Why it happens
SaveChanges() had one modified entity to write, so it expected the database to
report one affected row. It reported zero, because the row had been deleted by a second
context after the first one loaded it. The exception names both numbers and leaves the
decision to your code.
using var db = new ShopContext();
var maria = db.Customers.Single(c => c.Name == "Maria Garcia");
// another user deletes the row after we loaded it
using (var other = new ShopContext())
other.Database.ExecuteSql($"DELETE FROM Customers WHERE Name = 'Maria Garcia'");
maria.City = "Quito";
db.SaveChanges();
The Customer model in this run has no [Timestamp] or
[ConcurrencyCheck] property; the deleted row alone was enough to raise the
exception. The first context's code did nothing wrong; the problem came from a second
connection the first one knew nothing about.
That is what makes the error hard to see on a single-user machine: it needs two writers,
here simulated by two contexts in one program.
The fix
...
maria.City = "Quito";
try
{
db.SaveChanges();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var current = entry.GetDatabaseValues();
if (current is null)
{
Console.WriteLine("Row was deleted by someone else; nothing saved.");
entry.State = EntityState.Detached;
}
else
{
throw;
}
}
}
Console.WriteLine($"Rows left: {db.Customers.Count()}");
PS> dotnet run -- conc-fix
...
Row was deleted by someone else; nothing saved.
Rows left: 1
The run took the deleted branch: GetDatabaseValues() returned
null, the entry was detached, Maria Garcia's new city was not saved, and one of
the two seeded customers was left. The case of a row that still exists but was changed
by someone else is not covered here: the else branch rethrows the exception
so it is not lost silently, and the changed-row case was not run.
How it was reproduced
A console project from dotnet new console -n EfLab on .NET SDK 10.0.403 with
Microsoft.EntityFrameworkCore.Sqlite 10.0.14 and a SQLite file shop.db, seeded
with two fictional customers. One context loaded Maria Garcia, a second context deleted her
row with ExecuteSql, and the first context's SaveChanges() threw.
dotnet run -- conc-fix ran the same steps with the catch block.
Frequently asked
- What does expected to affect 1 row(s) but actually affected 0 row(s) mean in EF Core?
- SaveChanges wrote a change for a row it had loaded and the database reported that zero rows were affected. In the reproduction the row had been deleted by another connection after it was loaded.
- How do I handle DbUpdateConcurrencyException?
- Catch it, loop over ex.Entries and call GetDatabaseValues on each entry. In the reproduction a null result meant the row was deleted, so the entry was detached and nothing was saved; any other case was rethrown, because it was not tested.
- Do I need a rowversion column to get DbUpdateConcurrencyException?
- No. This model had no concurrency token at all and still threw, because the row had been deleted before SaveChanges ran.
More decoded errors in the Fixes category.