You add a RowVersion
property to your entity, mark it with [Timestamp]
, and think you’re done with concurrency control. Then production hits: concurrency exceptions aren’t caught properly, API clients can’t handle the binary data, and you’re not sure if your timestamps are working.
SQL Server’s rowversion is powerful but has specific quirks that can trip up EF Core developers. Here’s how to implement it correctly.
Understanding SQL Server RowVersion
SQL Server’s rowversion
is not a timestamp despite the old name. It’s an 8-byte binary value that automatically increments whenever any column in the row changes:
CREATE TABLE [Products] (
[Id] int IDENTITY(1,1) NOT NULL,
[Name] nvarchar(100) NOT NULL,
[Price] decimal(18,2) NOT NULL,
[RowVersion] rowversion NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
Every time you UPDATE this row, SQL Server automatically changes the RowVersion
value. You never set it manually - the database engine handles everything.
EF Core RowVersion Implementation
The correct way to implement rowversion in EF Core:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
[Timestamp] // This maps to SQL Server rowversion
public byte[] RowVersion { get; set; }
}
The [Timestamp]
attribute tells EF Core this is a concurrency token that’s automatically generated by the database. EF Core includes it in WHERE clauses for UPDATE and DELETE operations:
UPDATE [Products]
SET [Name] = @p0, [Price] = @p1
WHERE [Id] = @p2 AND [RowVersion] = @p3
If another process changed the row since you loaded it, the RowVersion
won’t match and the UPDATE affects 0 rows, triggering a DbUpdateConcurrencyException
.
Common RowVersion Mistakes
Using DateTime Instead of byte[]
This is wrong:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
[Timestamp]
public DateTime RowVersion { get; set; } // Wrong type!
}
EF Core maps this to SQL Server’s datetime
type, not rowversion
. You lose the automatic versioning behavior and get manual timestamp management instead.
Trying to Set RowVersion Values
Don’t do this:
// Wrong: You don't set rowversion manually
var product = new Product
{
Name = "Widget",
Price = 19.99m,
RowVersion = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // SQL Server ignores this
};
SQL Server ignores any value you provide for rowversion columns. The database assigns the value automatically on INSERT and UPDATE.
Forgetting RowVersion in Projections
When you use projections for updates, include the RowVersion:
// Wrong: Missing RowVersion
var product = await context.Products
.Where(p => p.Id == productId)
.Select(p => new Product
{
Id = p.Id,
Name = p.Name,
Price = p.Price
// Missing RowVersion - concurrency checking won't work
})
.SingleAsync();
// Correct: Include RowVersion
var product = await context.Products
.Where(p => p.Id == productId)
.Select(p => new Product
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
RowVersion = p.RowVersion // Essential for concurrency
})
.SingleAsync();
Without RowVersion in your projection, EF Core can’t perform concurrency checking.
Handling RowVersion in APIs
Converting binary RowVersion to JSON requires Base64 encoding:
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string RowVersion { get; set; } // Base64-encoded
public static ProductDto FromEntity(Product product)
{
return new ProductDto
{
Id = product.Id,
Name = product.Name,
Price = product.Price,
RowVersion = Convert.ToBase64String(product.RowVersion)
};
}
public void UpdateEntity(Product product)
{
product.Name = Name;
product.Price = Price;
// Don't update RowVersion - SQL Server handles it
}
}
In your API controller:
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, ProductDto dto)
{
try
{
var product = await context.Products.FindAsync(id);
if (product == null) return NotFound();
// Set original RowVersion for concurrency checking
if (!string.IsNullOrEmpty(dto.RowVersion))
{
var originalRowVersion = Convert.FromBase64String(dto.RowVersion);
context.Entry(product).OriginalValues[nameof(Product.RowVersion)] = originalRowVersion;
}
dto.UpdateEntity(product);
await context.SaveChangesAsync();
return Ok(ProductDto.FromEntity(product));
}
catch (DbUpdateConcurrencyException)
{
return Conflict("Product was modified by another user. Please refresh and try again.");
}
catch (FormatException)
{
return BadRequest("Invalid RowVersion format.");
}
}
Combining RowVersion with DateTime Timestamps
Often you need both concurrency control and actual timestamps:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
// Actual timestamps for business logic
public DateTime CreatedAt { get; set; }
public DateTime ModifiedAt { get; set; }
// Concurrency control
[Timestamp]
public byte[] RowVersion { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("GETUTCDATE()");
entity.Property(e => e.ModifiedAt)
.HasDefaultValueSql("GETUTCDATE()");
});
}
Configure automatic timestamp updates:
public override int SaveChanges()
{
UpdateTimestamps();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
UpdateTimestamps();
return base.SaveChangesAsync(cancellationToken);
}
private void UpdateTimestamps()
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
if (entry.Entity is Product product)
{
if (entry.State == EntityState.Added)
{
product.CreatedAt = DateTime.UtcNow;
}
product.ModifiedAt = DateTime.UtcNow;
}
}
}
Now you have both business timestamps and automatic concurrency control.
RowVersion Performance Considerations
Index Strategy
Don’t index RowVersion columns:
// Wrong: RowVersion indexes are rarely useful
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasIndex(p => p.RowVersion); // Usually unnecessary
}
RowVersion is primarily for concurrency checking, not querying. The values are database-specific and constantly changing, making indexes ineffective.
Batch Operations
RowVersion works with batch operations but requires careful handling:
public async Task<BatchUpdateResult> UpdateMultipleProducts(List<ProductUpdateDto> updates)
{
var results = new List<ProductUpdateResult>();
foreach (var update in updates)
{
try
{
var product = await context.Products.FindAsync(update.Id);
// Set original RowVersion for concurrency checking
if (!string.IsNullOrEmpty(update.RowVersion))
{
var originalRowVersion = Convert.FromBase64String(update.RowVersion);
context.Entry(product).OriginalValues[nameof(Product.RowVersion)] = originalRowVersion;
}
product.Name = update.Name;
product.Price = update.Price;
await context.SaveChangesAsync();
results.Add(new ProductUpdateResult
{
Id = product.Id,
Success = true,
NewRowVersion = Convert.ToBase64String(product.RowVersion)
});
}
catch (DbUpdateConcurrencyException)
{
results.Add(new ProductUpdateResult
{
Id = update.Id,
Success = false,
Error = "Concurrency conflict"
});
}
}
return new BatchUpdateResult { Results = results };
}
Testing RowVersion Behavior
Unit testing RowVersion requires some setup:
[Test]
public async Task UpdateProduct_WithStaleRowVersion_ThrowsConcurrencyException()
{
// Arrange
var product = new Product { Name = "Test", Price = 10.00m };
context.Products.Add(product);
await context.SaveChangesAsync();
var originalRowVersion = product.RowVersion;
// Simulate another user's update
var anotherContext = new ProductContext(options);
var sameProduct = await anotherContext.Products.FindAsync(product.Id);
sameProduct.Price = 15.00m;
await anotherContext.SaveChangesAsync();
// Act & Assert
product.Name = "Updated Name";
context.Entry(product).OriginalValues[nameof(Product.RowVersion)] = originalRowVersion;
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() =>
context.SaveChangesAsync());
}
Troubleshooting RowVersion Issues
Exception: “Database operation expected to affect 1 row(s) but actually affected 0 row(s)”
This means the RowVersion didn’t match. Check:
- Are you including RowVersion in your projections?
- Are you setting OriginalValues correctly for updates?
- Is the RowVersion value getting corrupted during serialization?
RowVersion Always Null
This usually means:
- Missing
[Timestamp]
attribute - Wrong property type (should be
byte[]
) - EF Core configuration issues
Concurrency Exceptions Not Caught
Make sure you’re catching the right exception type:
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex) // Specific exception type
{
// Handle concurrency conflict
}
catch (DbUpdateException ex) // More general - includes other database errors
{
// Handle other database errors
}
Pro Tip: I always include RowVersion in my API responses even for read operations. Clients need the current version to perform safe updates later. Without it, you’re back to the lost update problem.
When to Use RowVersion vs DateTime
Use RowVersion when:
- You need automatic concurrency control
- Multiple users can modify the same records
- You want database-managed versioning
- Performance is critical (binary comparison is fast)
Use DateTime timestamps when:
- You need to display “last modified” to users
- Business logic depends on actual times
- You’re implementing audit trails
- Cross-database portability matters
Use both when:
- You need both concurrency control and business timestamps
- You’re building enterprise applications
- Data integrity is critical
Key Takeaway
SQL Server’s rowversion provides robust, automatic concurrency control with minimal overhead. The key is understanding it’s not actually a timestamp - it’s a version counter that changes on every update.
Implement it correctly with byte[]
properties, handle the Base64 conversion in APIs, and combine it with DateTime properties when you need actual timestamps. Your application will be protected against lost updates without the complexity of pessimistic locking.