How to Use Transactions in EF Core for Reliable Changes

When I first started using Entity Framework Core seriously, I thought transactions were something “already handled by the database”. I remember thinking, why should I worry about transactions when SQL Server already knows how to keep data safe?

That mindset worked for simple CRUD screens. But the moment I started working on real business flows like payments, inventory, onboarding, or anything that touched money or multiple tables, I learned the hard way that relying on implicit behavior is not enough.

This post is me sharing what I learned about EF Core transactions from real projects. Not theory. Not perfect patterns. Just things that saved me from broken data, angry users, and late night fixes.

If you are building anything serious with EF Core, this is something you really want to understand.

The first time I broke production data

In one of my early projects, we had a simple flow that looked harmless.

  • Create an order
  • Deduct inventory
  • Create a payment record

Three steps. Three DbSet operations. One SaveChanges call at the end. It worked perfectly in development.

Until one day, production had a timeout during payment creation. The order was saved. Inventory was deducted. Payment was missing.

Now we had a paid looking order with no payment and negative inventory. Accounting was not happy. Operations was not happy. I was definitely not happy.

That was the day I stopped assuming EF Core would magically handle everything for me.

What EF Core does by default

This part is important and many developers miss it. By default, when you call SaveChanges or SaveChangesAsync, EF Core wraps that single call in a database transaction. So this works safely:

C#
context.Orders.Add(order);
context.SaveChanges();

If something fails during that SaveChanges call, everything is rolled back, but here is the catch. That transaction only applies to that one SaveChanges call. If your business logic looks like this:

C#
context.Orders.Add(order);
context.SaveChanges();

context.InventoryItems.Update(item);
context.SaveChanges();

context.Payments.Add(payment);
context.SaveChanges();

You now have three separate transactions.

  • If step two fails, step one is already committed.
  • If step three fails, step one and two are already committed.

That is exactly how I broke production.

When you actually need explicit transactions

In my experience, you need explicit transactions when

  • You modify multiple aggregates that must succeed or fail together
  • You call SaveChanges more than once inside a business operation
  • You mix database updates with external calls like APIs or message queues
  • You are dealing with money, balances, limits, or inventory

In fintech systems, this is almost every core flow.

Using transactions with EF Core the simple way

EF Core gives us a clean API for this through the database context. Here is the basic pattern I now use almost everywhere.

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

try
{
    // Step 1
    context.Orders.Add(order);
    await context.SaveChangesAsync();

    // Step 2
    inventory.Quantity -= order.Quantity;
    context.InventoryItems.Update(inventory);
    await context.SaveChangesAsync();

    // Step 3
    context.Payments.Add(payment);
    await context.SaveChangesAsync();

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

This guarantees one thing, either all steps succeed, or nothing is saved. No half orders. No missing payments. No negative inventory.

Why I still call SaveChanges multiple times inside a transaction

This is something people often ask me. Why not just add everything and call SaveChanges once? In real projects, it is not always possible.

  • Sometimes you need the generated OrderId before creating child records.
  • Sometimes validation depends on values generated by the database.
  • Sometimes you want to fail early before doing expensive steps.

Transactions give you that flexibility while still keeping data safe.

A real business example from my projects

In one system I worked on, we had this flow.

  1. Validate merchant
  2. Create transaction record
  3. Reserve balance
  4. Send transaction to external switch
  5. Update transaction status

The external switch could fail or timeout. We decided that once we reserve balance, everything must either complete or rollback.

Here is a simplified version.

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

try
{
    var txn = new Transaction
    {
        Amount = amount,
        Status = "Pending"
    };

    context.Transactions.Add(txn);
    await context.SaveChangesAsync();

    merchant.Balance -= amount;
    await context.SaveChangesAsync();

    var result = await switchClient.SendAsync(txn);

    txn.Status = result.Success ? "Approved" : "Failed";
    await context.SaveChangesAsync();

    if (!result.Success)
        throw new Exception("Switch rejected transaction");

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

If the switch failed, the balance deduction was rolled back automatically. This saved us from so many reconciliation problems later.

Transactions and business validation

One mistake I made early was mixing validation and transactions badly. If validation can fail, try to fail before starting a transaction.

For example.

  • Check merchant status
  • Check balance
  • Check limits

Then start the transaction only when you are sure you want to modify data. This keeps transactions short and avoids unnecessary locks.

Transactions across multiple DbContexts

I learned this the hard way too. If you are using multiple DbContexts pointing to the same database, each context has its own connection. A transaction created in one context does not automatically apply to the other. EF Core allows sharing transactions but it is more advanced and easy to misuse.

My rule now is simple.

One business operation uses one DbContext. If that is not possible, step back and rethink the design.

Async and transactions

Always use async versions when working with transactions. Mixing sync and async can cause connection issues under load.

Use

  • BeginTransactionAsync
  • CommitAsync
  • RollbackAsync

It matters more than it looks when your system is under pressure.

Common mistakes I still see

  • Relying on implicit SaveChanges transactions for complex flows
  • Catching exceptions and forgetting to rollback
  • Doing external API calls before committing or rolling back
  • Long running transactions with too many steps
  • Assuming transactions fix bad business logic

Transactions are a safety net, not a replacement for good design.

When not to use transactions

This may sound strange but not everything needs a transaction.

  • Simple read only queries
  • Independent updates that do not affect each other
  • Logging or audit trails that should always be written

Overusing transactions can cause performance issues and deadlocks. Use them where correctness matters most.

A quick note about the code examples

Before we wrap up, I want to clarify one important thing.

The code snippets used in this article are not taken from my actual production projects. They are intentionally simplified and recreated purely for demonstration and learning purposes. In real systems, especially the ones I worked on involving payments, inventory, or financial data, the logic is more complex and includes additional concerns like validation layers, logging, idempotency checks, security controls, and monitoring.

The goal of these examples is to make the transaction concepts easy to understand, without exposing real business logic or sensitive implementation details. Think of them as teaching samples, not copy paste production code.

Final thoughts

Transactions in EF Core are not complicated. What is complicated is knowing when you really need them. I learned this through broken data, production issues, and uncomfortable calls explaining what happened. If you are building systems that handle money, inventory, or anything critical, do not leave this to chance.

Be explicit. Be intentional. Your future self will thank you.

Sources

Microsoft Documentation: Entity Framework Core Transactions
https://learn.microsoft.com/en-us/ef/core/saving/transactions

Microsoft Documentation: SaveChanges behavior and implicit transactions
https://learn.microsoft.com/en-us/ef/core/saving/basic

Jimmy Bogard Blog
https://jimmybogard.com

Martin Fowler: Patterns of Enterprise Application Architecture
Transaction Script and Unit of Work concepts

Assi Arai
Assi Arai
Articles: 53