Skip to main content

How to Use Savepoints in EF Core

· 4 min read
Jagdish Kumawat
Founder @ Dewiride

A savepoint lets part of a transaction fail while the rest carries on. In EF Core, you create one with CreateSavepointAsync, and EF Core also creates its own around each SaveChanges inside a transaction. If you've seen __EFSavePoint in MySQL's log, that's where it comes from.

Prerequisites​

Step 1: Create One, and Roll Back to It​

This saves one category, then marks a savepoint, then tries a second that fails on the unique index. The first category should survive:

An endpoint
g.MapPost("/savepoint", async (InventoryDbContext db, TimeProvider clock, ILogger<InventoryDbContext> log, CancellationToken cancellationToken) =>
{
var strategy = db.Database.CreateExecutionStrategy();
var now = clock.GetUtcNow().UtcDateTime;

return await strategy.ExecuteAsync(
async ct =>
{
db.ChangeTracker.Clear();

await using var transaction = await db.Database.BeginTransactionAsync(ct);

db.Categories.Add(new Category { Name = "Stands", CreatedAtUtc = now });
await db.SaveChangesAsync(ct);

await transaction.CreateSavepointAsync("BeforeMounts", ct);

try
{
db.Categories.Add(new Category { Name = "Cables", CreatedAtUtc = now }); // This name already exists.
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException exception) when (exception.IsDuplicateKey())
{
await transaction.RollbackToSavepointAsync("BeforeMounts", ct);
db.ChangeTracker.Clear();
}

await transaction.CommitAsync(ct);

return Results.Ok(new { supportsSavepoints = transaction.SupportsSavepoints });
},
cancellationToken);
});

The endpoint in a macOS-style code window, with CreateSavepointAsync, a try and catch around SaveChangesAsync, and RollbackToSavepointAsync in the catch.

  • ChangeTracker.Clear() in the catch removes the failed entity. Otherwise, the next save tries it again.
  • SupportsSavepoints is true for this driver, as the response shows.
Output
{
"supportsSavepoints": true
}

Step 2: See What MySQL Got​

Output (MySQL's log)
+--------------------------------------------------------------+
| statement |
+--------------------------------------------------------------+
| SET TRANSACTION ISOLATION LEVEL REPEATABLE READ |
| BEGIN |
| SAVEPOINT `__EFSavePoint` |
| INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (ti |
| SELECT `Id` FROM `categories` WHERE ROW_COUNT() = 1 AND `Id` |
| RELEASE SAVEPOINT `__EFSavePoint` |
| SAVEPOINT `BeforeMounts` |
| SAVEPOINT `__EFSavePoint` |
| INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (ti |
| ROLLBACK TO `__EFSavePoint` |
| RELEASE SAVEPOINT `__EFSavePoint` |
| ROLLBACK TO `BeforeMounts` |
| COMMIT |
+--------------------------------------------------------------+

The general query log with SAVEPOINT BeforeMounts, automatic __EFSavePoint savepoints, ROLLBACK TO BeforeMounts and then COMMIT.

My savepoint is the `BeforeMounts` one. All the `__EFSavePoint` lines are EF Core's own.

Step 3: Understand the Automatic Ones​

The EF Core docs explain: "When SaveChanges is invoked and a transaction is already in progress on the context, EF automatically creates a savepoint before saving any data." If the save fails, "it automatically rolls the transaction back to the savepoint, leaving the transaction in the same state as if it had never started."

You can see that in the log: ROLLBACK TO __EFSavePoint came first, from EF Core, and then ROLLBACK TO BeforeMounts, from my catch.

To turn them off:

An endpoint (excerpt)
db.Database.AutoSavepointsEnabled = false;
Output (MySQL's log, with them off)
+--------------------------------------------------------------+
| statement |
+--------------------------------------------------------------+
| SET TRANSACTION ISOLATION LEVEL REPEATABLE READ |
| BEGIN |
| INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (ti |
| SELECT `Id` FROM `categories` WHERE ROW_COUNT() = 1 AND `Id` |
| ROLLBACK |
+--------------------------------------------------------------+

There's rarely a reason to, except to save the round trips in a tight loop.

Step 4: Remember That Locks Stay​

Rolling back to a savepoint undoes the changes, but MySQL doesn't release locks taken before it. So it isn't a way out of a lock conflict.

Common Mistakes​

MistakeWhat happensDo this instead
Using one to fake a nested transactionIt's one transaction, and a later failure loses everythingKeep them for partial retries
Forgetting to clear the trackerThe next save tries the failed entity againClear it, or detach it
Rolling back to a name that doesn't existMySQL raises an errorCheck the name

Troubleshooting​

SymptomLikely causeFix
__EFSavePoint in the logA SaveChanges inside a transactionThat's normal
The same failure again on the next saveThe entity's still in the trackerClear it
Row locks remain after the rollbackThey do, until the commitKeep transactions short

Conclusion​

Use CreateSavepointAsync and RollbackToSavepointAsync when part of a transaction may fail and the rest should still commit. Clear the tracker after rolling back, and don't be surprised by EF Core's own __EFSavePoint.

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.