Batch database operations are useful when an application needs to insert, update, or delete many records in one request. Instead of sending a separate database command for every record, the application can group related changes into a single operation and reduce network overhead.
However, batch processing introduces an important reliability problem. What happens when the database successfully updates some records but fails before completing the rest?
Imagine an order management system updating 100 inventory records after a warehouse shipment arrives. The first 60 updates succeed, but the remaining updates fail because one record violates a database constraint. If the application does not handle the operation correctly, the inventory may reflect only part of the shipment.
These inconsistencies can be difficult to detect because the application may report an error even though some changes have already been committed.
Database transactions help prevent partial updates by making a group of related operations succeed or fail as a unit. In this article, we will examine how partial updates happen, how to handle them in C# with SQL Server and Entity Framework Core, and when alternative strategies are more appropriate.
Why Batch Operations Can Leave Inconsistent Data
Consider an application that transfers money between two accounts. The application needs to subtract money from the source account and add it to the destination account.
A simplified implementation might look like this:
public async Task TransferAsync(
int sourceAccountId,
int destinationAccountId,
decimal amount)
{
await _repository.DebitAsync(
sourceAccountId, amount);
await _repository.CreditAsync(
destinationAccountId, amount);
}The code appears straightforward, but the two database operations may run in separate transactions. If the debit succeeds and the credit fails, money is removed from one account without being added to the other.
The same problem can occur during bulk imports, inventory adjustments, payroll processing, and order fulfillment.
Common causes include:
- A database constraint rejects one record.
- A connection fails while the batch is running.
- A command times out after some operations have completed.
- Application code commits changes before all related operations finish.
- Multiple database calls use separate transactions.
- An error occurs after the database has committed but before the application receives confirmation.
The last case deserves particular attention. A network failure does not always mean the database rolled back the operation. The database may have committed successfully while the client lost the response. Retrying blindly can therefore introduce duplicate changes.
Use Database Transactions for Atomicity
A transaction groups related database operations into a single unit of work. When the transaction commits, all its changes become durable. When it rolls back, its uncommitted changes are discarded.
This property is known as atomicity, the first property in the ACID transaction model.
For SQL Server, the basic pattern is:
BEGIN TRANSACTION;
BEGIN TRY
UPDATE Accounts
SET Balance = Balance - 100.00
WHERE AccountId = 1;
UPDATE Accounts
SET Balance = Balance + 100.00
WHERE AccountId = 2;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;Both updates execute inside the same transaction. If an error occurs before the commit, the catch block rolls back the transaction and rethrows the error so the caller can handle it.
This example demonstrates transaction boundaries, but a real money transfer must also validate the amount, confirm that both accounts exist, prevent an invalid negative balance, and verify that each update affected the expected number of rows.
For example, an update that affects zero rows is not necessarily a database exception. If the source account does not exist, the debit statement may complete without throwing an error. Application logic must check affected-row counts or use stronger database constraints to enforce its business rules.
Prevent Partial Updates with Entity Framework Core
Entity Framework Core normally wraps the changes from a single SaveChangesAsync call in a transaction when the database provider supports transactions.
For many applications, this means multiple tracked entity changes can be saved atomically without manually creating a transaction.
Consider an inventory adjustment that updates several products:
public async Task UpdateInventoryAsync(
IReadOnlyCollection<InventoryAdjustment> adjustments,
CancellationToken cancellationToken)
{
foreach (var adjustment in adjustments)
{
var product = await _dbContext.Products
.SingleOrDefaultAsync(
p => p.Id == adjustment.ProductId,
cancellationToken);
if (product is null)
{
throw new InvalidOperationException(
$"Product {adjustment.ProductId} was not found.");
}
int newQuantity =
product.Quantity + adjustment.QuantityChange;
if (newQuantity < 0)
{
throw new InvalidOperationException(
$"Insufficient inventory for product {product.Id}.");
}
product.Quantity = newQuantity;
}
await _dbContext.SaveChangesAsync(cancellationToken);
}The method loads and validates the affected products, updates the tracked entities, and calls SaveChangesAsync once. When that save uses EF Core's normal transactional behavior, a database failure during the save rolls back the changes made by that call.
However, the guarantee has boundaries:
- If the method performs an earlier
SaveChangesAsync, those earlier changes may already be committed. - If another method saves through a different context, it may use a separate transaction.
- External API calls are not automatically rolled back with a database transaction.
- Validation alone cannot prevent concurrent requests from changing inventory after it was read.
For a batch that requires strict consistency, keep the relevant changes within a clearly defined transaction and enforce important business rules at the database boundary.
Use an Explicit Transaction When Multiple Saves Are Required
Sometimes an application must save changes more than once within the same logical operation. In that case, an explicit transaction can keep those database changes together.
public async Task ProcessBatchAsync(
IReadOnlyCollection<BatchItem> items,
CancellationToken cancellationToken)
{
await using var transaction =
await _dbContext.Database.BeginTransactionAsync(
cancellationToken);
try
{
foreach (var item in items)
{
var entity = new ProcessedItem
{
ExternalId = item.ExternalId,
Status = "Processed"
};
_dbContext.ProcessedItems.Add(entity);
}
await _dbContext.SaveChangesAsync(cancellationToken);
await _dbContext.BatchAudits.AddAsync(
new BatchAudit
{
ProcessedCount = items.Count,
CreatedAtUtc = DateTimeOffset.UtcNow
},
cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(
cancellationToken);
throw;
}
}
Join the conversation! Your thoughts help the community grow.