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.