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.