Skip to main content

EF Core savepoints: rolling back part of a transaction

There's a moment when a transaction fails halfway through, and you realize rolling back everything is overkill. You did five inserts, the sixth one violates a constraint, and you'd rather undo just that last operation than start the whole transaction over.

That's what savepoints are for, and EF Core has supported them for a while now but until recently I didn't know this feature existed.


What's the problem with plain transactions?

A regular database transaction is all-or-nothing. Something like this:

using var transaction = await context.Database.BeginTransactionAsync();

try
{
    context.Blogs.Add(new Blog { Url = "https://blog1.com" });
    await context.SaveChangesAsync();

    context.Blogs.Add(new Blog { Url = "https://blog2.com" });
    await context.SaveChangesAsync();

    // this one fails
    context.Blogs.Add(new Blog { Url = null });
    await context.SaveChangesAsync();

    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
}

If the third SaveChangesAsync fails, the whole transaction gets rolled back. Blog1 and Blog2 are gone too, even though nothing was wrong with them. In a long-running transaction with many steps, that's expensive and often unnecessary.

Savepoints: rolling back to a specific point

A savepoint lets you mark a point inside a transaction, and later roll back to that point instead of the beginning. EF Core exposes this through CreateSavepoint, RollbackToSavepoint, and ReleaseSavepoint on the transaction object.

using var transaction = await context.Database.BeginTransactionAsync();

context.Blogs.Add(new Blog { Url = "https://blog1.com" });
await context.SaveChangesAsync();

await transaction.CreateSavepointAsync("BeforeSecondBlog");

context.Blogs.Add(new Blog { Url = "https://blog2.com" });
await context.SaveChangesAsync();

try
{
    context.Blogs.Add(new Blog { Url = null });
    await context.SaveChangesAsync();
}
catch
{
    await transaction.RollbackToSavepointAsync("BeforeSecondBlog");
}

await transaction.CommitAsync();

Blog1 stays committed as part of the eventual commit, blog2's failed insert gets undone, and you can keep going from there — add a corrected blog2, or just move on. The rest of the transaction survives.

Remark: RollbackToSavepointAsync doesn't end the transaction. It puts you back at the state the savepoint captured, and you can continue issuing commands, create more savepoints, or commit normally afterwards.

Where this actually helps

Savepoints make the most sense in a few recurring scenarios:

  • Batch processing — you're inserting a large number of related records in one transaction, and one bad record shouldn't cost you the whole batch.
  • Optional steps — a step that's "nice to have" but not required for the transaction to make sense. Try it, savepoint before it, roll back if it fails, keep going.
  • Retry logic within a transaction — instead of catching an exception and restarting the entire transaction, you retry from a savepoint closer to the failure.

Remark: Not every database provider supports savepoints the same way. SQL Server and PostgreSQL both support them through EF Core; check your provider's docs if you're on something less common. Also, SaveChangesAsync itself wraps its own operations in an implicit transaction unless you're already inside one. Savepoints only make sense once you're explicitly managing a transaction yourself.

Automatic savepoints

There's a detail worth knowing: EF Core creates savepoints automatically in some cases. When you call SaveChangesAsync multiple times within a user-initiated transaction, EF Core will insert a savepoint before each call so that if that particular SaveChangesAsync fails, it can roll back just that batch of changes and leave your transaction usable rather than aborting it outright. You get some of this behavior for free, without calling CreateSavepointAsync yourself.

That's it! A small feature, but a useful one to have in your back pocket for anything that touches multi-step transactions.

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

Azure DevOps/ GitHub emoji

I’m really bad at remembering emoji’s. So here is cheat sheet with all emoji’s that can be used in tools that support the github emoji markdown markup: All credits go to rcaviers who created this list.