Your application works perfectly in development, but in production, you’re seeing partial data updates and mysterious consistency issues. The problem isn’t your business logic, it’s how you’re handling transactions.

EF Core gives you two ways to manage transactions: the automatic approach with SaveChanges, and explicit transaction control. Each has its place, but using the wrong one can cost you data integrity or performance.

How EF Core SaveChanges Handles Transactions

SaveChanges automatically wraps all your changes in a single database transaction. This means multiple entity operations either all succeed or all fail together:

// This entire operation is one transaction
using var context = new OrderContext();

var customer = new Customer { Name = "John Doe" };
var order = new Order { CustomerId = customer.Id, Total = 100.50m };
var orderItem = new OrderItem { OrderId = order.Id, ProductId = 1 };

context.Customers.Add(customer);
context.Orders.Add(order);
context.OrderItems.Add(orderItem);

await context.SaveChangesAsync(); // All or nothing

If any part fails, everything rolls back. Your database stays consistent without any extra code.

When SaveChanges Transactions Aren’t Enough

Multiple SaveChanges Calls

The automatic transaction only covers a single SaveChanges call. If your business logic requires multiple save operations, you need explicit control:

// Problem: Two separate transactions
public async Task ProcessOrderAsync(Order order)
{
    // Transaction 1
    context.Orders.Add(order);
    await context.SaveChangesAsync();
    
    // Transaction 2 - could fail, leaving orphaned order
    await UpdateInventoryAsync(order.Items);
    await context.SaveChangesAsync();
}

If the inventory update fails, you’re left with an order but no inventory adjustment. That’s a data consistency nightmare.

Cross-Service Operations

When your business logic spans multiple services or external APIs, SaveChanges can’t protect you:

// Dangerous: No transaction coordination
public async Task CompleteOrderAsync(int orderId)
{
    var order = await context.Orders.FindAsync(orderId);
    order.Status = OrderStatus.Completed;
    await context.SaveChangesAsync(); // Committed to database
    
    // If this fails, order is marked complete but payment wasn't processed
    await paymentService.ProcessPaymentAsync(order.PaymentId);
}

The order gets marked as complete even if payment processing fails. Your business is now tracking completed orders that were never paid for.

Explicit Transaction Control

EF Core gives you full transaction control when you need it:

public async Task ProcessOrderWithExplicitTransaction(Order order)
{
    using var transaction = await context.Database.BeginTransactionAsync();
    
    try
    {
        // Add the order
        context.Orders.Add(order);
        await context.SaveChangesAsync();
        
        // Update inventory
        foreach (var item in order.Items)
        {
            var product = await context.Products.FindAsync(item.ProductId);
            product.StockQuantity -= item.Quantity;
        }
        await context.SaveChangesAsync();
        
        // Process external payment
        var paymentResult = await paymentService.ProcessAsync(order.Total);
        if (!paymentResult.Success)
        {
            throw new PaymentException(paymentResult.Error);
        }
        
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Now everything succeeds together or fails together, even across service boundaries.

Transaction Scope for Complex Operations

For operations spanning multiple contexts or services, use TransactionScope:

public async Task TransferOrderBetweenDatabases(int orderId, int targetTenantId)
{
    using var scope = new TransactionScope(
        TransactionScopeOption.Required,
        new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
        TransactionScopeAsyncFlowOption.Enabled);
    
    try
    {
        // Source database
        using var sourceContext = new OrderContext(sourceConnectionString);
        var order = await sourceContext.Orders.FindAsync(orderId);
        sourceContext.Orders.Remove(order);
        await sourceContext.SaveChangesAsync();
        
        // Target database
        using var targetContext = new OrderContext(targetConnectionString);
        targetContext.Orders.Add(order);
        await targetContext.SaveChangesAsync();
        
        scope.Complete();
    }
    catch
    {
        // Transaction automatically rolls back
        throw;
    }
}

This coordinates transactions across multiple database connections, ensuring data moves atomically between tenants.

Performance Considerations

Lock Duration

Explicit transactions hold database locks longer. Keep them as short as possible:

// Bad: Long-running transaction
using var transaction = await context.Database.BeginTransactionAsync();
var orders = await context.Orders.Where(o => o.Status == OrderStatus.Pending).ToListAsync();

foreach (var order in orders) // Could be thousands of orders
{
    await ProcessSingleOrderAsync(order); // External API calls
}

await transaction.CommitAsync(); // Locks held for minutes
// Better: Batch processing with shorter transactions
var pendingOrderIds = await context.Orders
    .Where(o => o.Status == OrderStatus.Pending)
    .Select(o => o.Id)
    .ToListAsync();

foreach (var batch in pendingOrderIds.Chunk(50))
{
    using var transaction = await context.Database.BeginTransactionAsync();
    
    foreach (var orderId in batch)
    {
        await ProcessSingleOrderAsync(orderId);
    }
    
    await transaction.CommitAsync(); // Shorter lock duration
}

Deadlock Prevention

SQL Server deadlocks happen when transactions access resources in different orders. Always access tables consistently:

// Deadlock-prone: Inconsistent table access order
public async Task TransferBetweenAccounts(int fromId, int toId, decimal amount)
{
    using var transaction = await context.Database.BeginTransactionAsync();
    
    var fromAccount = await context.Accounts.FindAsync(fromId);
    var toAccount = await context.Accounts.FindAsync(toId);
    
    fromAccount.Balance -= amount;
    toAccount.Balance += amount;
    
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}
// Deadlock-resistant: Consistent ordering
public async Task TransferBetweenAccounts(int fromId, int toId, decimal amount)
{
    using var transaction = await context.Database.BeginTransactionAsync();
    
    // Always access accounts in ID order
    var minId = Math.Min(fromId, toId);
    var maxId = Math.Max(fromId, toId);
    
    var accounts = await context.Accounts
        .Where(a => a.Id == minId || a.Id == maxId)
        .OrderBy(a => a.Id)
        .ToListAsync();
    
    var fromAccount = accounts.First(a => a.Id == fromId);
    var toAccount = accounts.First(a => a.Id == toId);
    
    fromAccount.Balance -= amount;
    toAccount.Balance += amount;
    
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}

Error Handling and Retry Logic

Database operations can fail due to deadlocks, timeouts, or connectivity issues. Implement proper retry logic:

public async Task<bool> ProcessOrderWithRetry(Order order, int maxRetries = 3)
{
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        try
        {
            using var transaction = await context.Database.BeginTransactionAsync();
            
            context.Orders.Add(order);
            await context.SaveChangesAsync();
            
            await UpdateInventoryAsync(order.Items);
            await context.SaveChangesAsync();
            
            await transaction.CommitAsync();
            return true;
        }
        catch (SqlException ex) when (ex.Number == 1205) // Deadlock
        {
            if (attempt == maxRetries) throw;
            
            // Exponential backoff
            var delay = TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1));
            await Task.Delay(delay);
        }
    }
    
    return false;
}

Pro Tip: I’ve seen production systems go down because developers didn’t handle SQL Server deadlocks. Always implement retry logic for transaction-heavy operations, especially during high-traffic periods.

Isolation Levels and Performance

Different isolation levels offer different consistency guarantees and performance characteristics:

// For read-heavy scenarios with acceptable dirty reads
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.ReadUncommitted);

// For balance between consistency and performance
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted);

// For strict consistency requirements
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

Read Uncommitted gives best performance but allows dirty reads. Serializable prevents all anomalies but can cause significant blocking.

When to Use Each Approach

Use SaveChanges automatic transactions when:

  • Single SaveChanges call handles all changes
  • No external service coordination needed
  • Simple CRUD operations
  • Maximum simplicity is preferred

Use explicit transactions when:

  • Multiple SaveChanges calls needed
  • Coordinating with external services
  • Complex business logic with rollback scenarios
  • Fine-grained control over isolation levels
  • Handling potential deadlocks with retry logic

Key Takeaway

EF Core’s automatic transactions with SaveChanges work great for simple scenarios, but production applications often need explicit transaction control for proper consistency guarantees.

The key is understanding when your business logic outgrows automatic transactions and making the switch before data consistency issues appear in production. Your future self will thank you for the investment in proper transaction handling.