Skip to main content

Fixed: DbUpdateConcurrencyException "The database operation was expected to affect 1 row(s), but actually affected 0 row(s)" in EF Core

· 9 min read
Jagdish Kumawat
Founder @ Dewiride

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. EF Core expected its UPDATE to change one row, and MySQL said none. I got this message three different ways while building an API, and each needs a different answer. Telling them apart takes one look at the SQL.

What I Was Doing​

In a .NET 10 Web API with EF Core 10, Oracle's MySQL provider and MySQL 9.7, products have a Version column configured with IsConcurrencyToken(). Clients send back the version they read, and an update tells EF Core to compare against it:

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
db.Entry(product).Property(p => p.Version).OriginalValue = request.Version;

// …set the new values…
product.Version = request.Version + 1;

await db.SaveChangesAsync(cancellationToken);

Two clients read a product at version 0. The first one saved, which moved it to version 1. Then the second one saved, still sending version 0.

What I Expected​

That the second save wouldn't overwrite the first. That's the point of the token, and it's what happened, but I hadn't decided yet what the client should see.

What I Got​

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-e66ac3c9326121b7f9cbab55f79ea73f-8995ba6ed3721cbf-00"
}
API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p4='?' (DbType = Int32), @p0='?' (DbType = Int32), @p1='?' (DbType = Decimal), @p2='?' (DbType = DateTime), @p3='?' (DbType = Int32), @p5='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
UPDATE `products` SET `ReorderLevel` = @p0, `UnitPrice` = @p1, `UpdatedAtUtc` = @p2, `Version` = @p3
WHERE `Id` = @p4 AND `Version` = @p5;
SELECT ROW_COUNT();
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
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.

A terminal with a 500 response, and the API log with an UPDATE statement that has WHERE Id and Version, followed by DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s).

What I Checked​

The SELECT ROW_COUNT(). Every UPDATE and DELETE from SaveChangesAsync comes with it. EF Core reads the number and compares it with how many rows it meant to change. The WHERE clause shows what had to match: Id and Version. The row exists, but with version 1, so nothing matched.

What the documentation says. EF Core's concurrency page describes this: "if a concurrent update occurred, the UPDATE fails to find any matching rows and reports that zero were affected. As a result, EF Core's SaveChanges() throws a DbUpdateConcurrencyException, which the application must catch and handle appropriately."

Whether it needs a concurrency token. No. I got the same exception from two other situations, on a table with no token at all. Here's the categories table, where the row was deleted between loading and saving:

API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (Size = 100)], CommandType='Text', CommandTimeout='30']
UPDATE `categories` SET `Name` = @p0
WHERE `Id` = @p1;
SELECT ROW_COUNT();
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
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.

And here's Update on an object I made myself, for an ID that doesn't exist:

An endpoint that updates without loading first
var updated = new Category { Id = id, Name = "Renamed without loading", CreatedAtUtc = createdAt };
db.Categories.Update(updated);
await db.SaveChangesAsync();
API log (PUT with Id 9999)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p2='?' (DbType = Int32), @p0='?' (DbType = DateTime), @p1='?' (Size = 100)], CommandType='Text', CommandTimeout='30']
UPDATE `categories` SET `CreatedAtUtc` = @p0, `Name` = @p1
WHERE `Id` = @p2;
SELECT ROW_COUNT();
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
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.

Why It Happens​

SaveChangesAsync always checks how many rows each statement changed. Zero means the row it meant to change isn't there in the state it expected:

What happenedHow to recognise it
Someone changed the row, and the token no longer matchesVersion = @p in the WHERE, and the row still exists
Someone deleted the row after you loaded itOnly Id = @p in the WHERE, and you loaded it first
The row never existedYou called Update or Remove on an object you made yourself

One more cause to know about on MySQL: a connection string with UseAffectedRows=True makes ROW_COUNT() report changed rows, not matched ones, so a statement that writes the same values reports 0. Oracle's connector defaults to false, and What UseAffectedRows Does shows the difference.

The Fix​

Catch it, and answer what it means for your API. For a stale token, that's 409 Conflict:

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
return TypedResults.Problem(
title: "Someone else changed this product.",
detail: "Read the product again to get its current values and version, then repeat your change.",
statusCode: StatusCodes.Status409Conflict);
}

Catch it before any catch (DbUpdateException), because DbUpdateConcurrencyException is a kind of DbUpdateException.

For the third case, don't call Update on an object you made. Load the row first, answer 404 if it isn't there, and change the loaded object:

src/Dewiride.Inventory.Api/Features/Categories/CategoryEndpoints.cs (excerpt)
var category = await db.Categories
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);

if (category is null)
{
return TypedResults.NotFound();
}

category.Name = request.Name.Trim();

After the Fix​

The same stale request:

Response
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Someone else changed this product.",
"status": 409,
"detail": "Read the product again to get its current values and version, then repeat your change.",
"traceId": "00-13d625ac4413e53aa973b85a9e527f88-970c7b496071581e-00"
}

The stored row is unchanged, and when the client reads it again and sends the new version, the update goes through:

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
"id": 1,
"sku": "CBL-USBC-2M",
"name": "USB-C cable, 2 m",
"description": "Braided, 100 W",
"categoryId": 1,
"categoryName": "Cables",
"unitPrice": 10.49,
"quantityOnHand": 0,
"reorderLevel": 30,
"isArchived": false,
"archivedAtUtc": null,
"version": 2,
"createdAtUtc": "2026-11-04T10:48:57.349865Z",
"updatedAtUtc": "2026-11-04T10:49:38.54156Z"
}

A terminal where an update with an old version answers 409 Conflict with the message Someone else changed this product, and a retry with the current version answers 200 OK with version 2.

Common Mistakes​

MistakeWhat happensDo this instead
Catching it and saving again with no checksThe other person's change is overwritten after allAsk the client to read again
Answering 500A normal event looks like a server failureAnswer 409
catch (DbUpdateException) above itYour duplicate-key message answers a concurrency conflictCatch the concurrency one first
Calling Update on a new object without checkingA missing row gives this exception instead of a 404Load first, or use ExecuteUpdateAsync and check its count
Adding UseAffectedRows=True to the connection stringSaving identical values throws this exceptionLeave it at the default

Troubleshooting​

SymptomLikely causeFix
The exception on a table with a token, and the row existsA stale versionAnswer 409
The exception, and the row is goneDeleted between load and saveAnswer 404 or 409
The exception on Update of a new objectNo row has that IDLoad it first
The exception when nothing else is writing, and the values are the sameUseAffectedRows=True in the connection stringRemove it

FAQ​

Is this an error in my code?​

Often not. On a table with a token, it's the feature working: two people changed the same row, and it stopped one of them from overwriting the other.

Should I retry automatically?​

Only if your code can recompute the change from fresh values. EF Core's page shows that pattern. For a person editing a form, tell them, so they can decide.

Does EnableRetryOnFailure retry this?​

No. A concurrency conflict isn't a temporary error, and the exception reached the endpoint on the first attempt here. With Oracle's provider, retries need a list of error numbers anyway.

Conclusion​

"Expected to affect 1 row(s), but actually affected 0 row(s)" means ROW_COUNT() came back as 0: the row changed, disappeared, or never existed. The WHERE clause in the log tells you which. On a table with a concurrency token, answer 409 and let the client read again. Elsewhere, load the row first and answer 404 when it's missing.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.