← Back to all posts
dotnetazurecosmosdbcsharpconcurrency

Optimistic Concurrency with ETags in Azure Cosmos DB

Optimistic Concurrency with ETags in Azure Cosmos DB

The Problem

When multiple processes read and then update the same document, the last write wins β€” silently overwriting changes made by whoever got there first. This is known as the lost update problem.

Consider an inventory system. Two fulfillment workers both read a product document showing 10 units in stock:

Worker A reads:  { stockLevel: 10 }
Worker B reads:  { stockLevel: 10 }

Worker A writes: { stockLevel: 9 }  βœ“ (reserved 1 unit)
Worker B writes: { stockLevel: 9 }  βœ“ (reserved 1 unit β€” but stock was already 9!)

Both writes succeed. Both workers believe they reserved a unit. But the stock level is now 9, not 8 β€” one reservation was silently lost. The database never complained because neither write violated any constraint; each simply overwrote the previous value.

In relational databases, the standard defence is pessimistic locking: hold a row lock, make your change, release. In a distributed, globally replicated database like Azure Cosmos DB, that kind of locking is neither practical nor desirable. It would eliminate the throughput and availability guarantees that make Cosmos DB valuable in the first place.

The Solution

Cosmos DB assigns every document an ETag β€” a short opaque string that changes on every write. Think of it as an automatic row version stamp. You can read the ETag alongside a document and then pass it back when you write, telling Cosmos DB: β€œonly accept this write if the document is still at this version.” If someone else has written in the meantime, Cosmos DB rejects the write with a 412 Precondition Failed response.

This is optimistic concurrency control β€” rather than locking resources upfront (pessimistic), you assume conflicts are rare, attempt the write, and handle the rejection if a conflict does occur. For most workloads this is both cheaper and faster than locking.

The ETag is managed entirely by Cosmos DB:

  • It is created automatically when an item is first written
  • It changes on every successful replace, upsert, or patch
  • It is returned in the response body (_etag field) and as an HTTP ETag header
  • You never set it manually β€” you only read it and echo it back

Reading the ETag

Every item returned by the SDK exposes its ETag through the response headers. The simplest way to capture it is to map _etag directly onto your document model:

public sealed class ProductDocument
{
    [JsonPropertyName("id")]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [JsonPropertyName("sku")]
    public string Sku { get; set; } = string.Empty;

    [JsonPropertyName("name")]
    public string Name { get; set; } = string.Empty;

    [JsonPropertyName("stockLevel")]
    public int StockLevel { get; set; }

    [JsonPropertyName("price")]
    public decimal Price { get; set; }

    // Cosmos DB populates this automatically on every read or write.
    // Map it onto your model so it travels alongside the document.
    [JsonPropertyName("_etag")]
    public string? ETag { get; set; }
}

When you read an item, _etag is populated automatically:

public async Task<ProductDocument?> GetAsync(
    string productId,
    string partitionKey,
    CancellationToken cancellationToken = default)
{
    try
    {
        var response = await _container.ReadItemAsync<ProductDocument>(
            productId,
            new PartitionKey(partitionKey),
            cancellationToken: cancellationToken);

        // response.Resource._etag is now populated, e.g. "\"00000a00-0000-0d00-0000-6613e2340000\""
        return response.Resource;
    }
    catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
    {
        return null;
    }
}

Alternatively, you can read the ETag from response.ETag without mapping the _etag field β€” but embedding it in the document is the more convenient pattern because the version travels with the object through your application layers.

Performing a Conditional Write

To make a write conditional on the ETag, pass it in ItemRequestOptions.IfMatchEtag:

public async Task<ProductDocument> UpdateStockAsync(
    ProductDocument product,
    int delta,
    CancellationToken cancellationToken = default)
{
    product.StockLevel += delta;

    var response = await _container.ReplaceItemAsync(
        product,
        product.Id,
        new PartitionKey(product.Sku),
        new ItemRequestOptions
        {
            // Cosmos DB will reject this write with 412 if _etag has changed
            // since we last read the document
            IfMatchEtag = product.ETag
        },
        cancellationToken);

    // The returned document contains the new ETag for the updated version
    return response.Resource;
}

If another process has written to the document between our read and this write, Cosmos DB returns 412 Precondition Failed. The SDK surfaces this as a CosmosException with StatusCode == HttpStatusCode.PreconditionFailed.

Handling the Conflict

A 412 is not an error in the traditional sense β€” it is expected information. The correct response is to retry: re-read the document to get the latest state and ETag, reapply the business logic, and attempt the write again:

public async Task<ProductDocument> ReserveStockAsync(
    string productId,
    string sku,
    int quantity,
    CancellationToken cancellationToken = default)
{
    const int maxRetries = 5;

    for (var attempt = 0; attempt < maxRetries; attempt++)
    {
        var product = await GetAsync(productId, sku, cancellationToken);

        if (product is null)
            throw new InvalidOperationException($"Product {productId} not found.");

        if (product.StockLevel < quantity)
            throw new InvalidOperationException("Insufficient stock.");

        try
        {
            product.StockLevel -= quantity;

            return await _container.ReplaceItemAsync(
                product,
                product.Id,
                new PartitionKey(product.Sku),
                new ItemRequestOptions { IfMatchEtag = product.ETag },
                cancellationToken);
        }
        catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
        {
            // Another writer got here first β€” loop and retry with the fresh document
            if (attempt == maxRetries - 1)
                throw new InvalidOperationException(
                    $"Could not reserve stock after {maxRetries} attempts due to concurrent updates.", ex);
        }
    }

    // Unreachable, but satisfies the compiler
    throw new InvalidOperationException("Unexpected exit from retry loop.");
}

Each retry re-reads the freshest version of the document, so if another worker reduced stock to zero between attempts, the business logic check catches it before the conditional write, and an appropriate exception is thrown rather than producing incorrect data.

ETag vs. Optimistic Concurrency Token in EF Core

If you are familiar with Entity Framework Core, the ETag plays the same role as a concurrency token ([Timestamp] or [ConcurrencyCheck]). EF Core tracks the original value and sends it as a WHERE clause condition on UPDATE. Cosmos DB does the same via the IfMatchEtag header β€” the mechanism is HTTP-native rather than SQL, but the intent is identical: reject writes where the row version no longer matches what was read.

The difference is that ETags are completely server-managed. You never calculate or assign them; Cosmos DB handles the versioning. Your only responsibilities are:

  1. Preserve the ETag when you read a document
  2. Echo it back in IfMatchEtag when you write
  3. Handle 412 by retrying from a fresh read

Upserting with ETag Protection

Conditional writes also apply to upserts. This is useful when you want to create-or-update atomically but still protect against a concurrent update racing with your own:

public async Task<ProductDocument> UpsertAsync(
    ProductDocument product,
    CancellationToken cancellationToken = default)
{
    var options = new ItemRequestOptions();

    if (product.ETag is not null)
    {
        // Document already exists β€” protect against concurrent modification
        options.IfMatchEtag = product.ETag;
    }
    // If ETag is null this is a new document β€” no version to guard against

    var response = await _container.UpsertItemAsync(
        product,
        new PartitionKey(product.Sku),
        options,
        cancellationToken);

    return response.Resource;
}

Patching with ETag Protection

The same IfMatchEtag option works with partial updates via PatchItemAsync, which is useful when you only need to change one or two fields without reading and rewriting the entire document:

public async Task<ProductDocument> AdjustPriceAsync(
    string productId,
    string sku,
    string etag,
    decimal newPrice,
    CancellationToken cancellationToken = default)
{
    var patches = new[]
    {
        PatchOperation.Set("/price", newPrice)
    };

    var response = await _container.PatchItemAsync<ProductDocument>(
        productId,
        new PartitionKey(sku),
        patches,
        new PatchItemRequestOptions { IfMatchEtag = etag },
        cancellationToken);

    return response.Resource;
}

The patch is atomic at the item level and is rejected with 412 if the ETag has changed, giving you fine-grained conflict detection even on partial updates.

Summary

ETags in Azure Cosmos DB are a lightweight, server-managed mechanism for preventing lost updates without the cost of locking. They function as an automatic row version stamp:

  • Read β€” every item carries a current ETag in _etag
  • Write β€” pass the ETag back in IfMatchEtag to make the write conditional
  • Conflict β€” Cosmos DB returns 412 Precondition Failed if the document was modified since the read
  • Retry β€” re-read the latest version, reapply business logic, and write again

The pattern works uniformly across ReplaceItemAsync, UpsertItemAsync, and PatchItemAsync, making it the standard approach to safe concurrent writes in Cosmos DB.