Skip to main content
Azure · lesson 13 of 19
Azure intermediate Lesson 13 of 19

Azure Cosmos DB: ETags and Patch

Patch a Cosmos DB item atomically with the Python SDK, then see what an optimistic-concurrency replace actually does: a stale ETag raises a 412, and the fix is reading the item's current ETag again before retrying, not just resubmitting the same write.

By SkillByExample · Published

azure cosmos-db nosql

Two different tools solve two different concurrency problems in Cosmos DB. patch_item applies a small operation directly on the server, atomically, with no read step to race against. replace_item with an ETag makes an ordinary read-modify-write safe by making the write fail loudly instead of silently overwriting someone else’s change. This lesson runs against the same vNext emulator image as the previous two, in its own fresh container.

patch_item Increments Without a Read-Modify-Write Race

from azure.cosmos import CosmosClient, PartitionKey

ENDPOINT = "http://localhost:8081"
KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="

client = CosmosClient(ENDPOINT, credential=KEY)
db = client.create_database_if_not_exists("storedb")
container = db.create_container_if_not_exists(
    id="orders", partition_key=PartitionKey(path="/customer")
)
for order in [
    {"id": "o0", "customer": "alice", "total": 30},
    {"id": "o1", "customer": "bob", "total": 12},
    {"id": "o2", "customer": "alice", "total": 55},
]:
    container.upsert_item(order)

before = container.read_item(item="o1", partition_key="bob")
print("o1 total before patch:", before["total"])

patched = container.patch_item(
    item="o1",
    partition_key="bob",
    patch_operations=[{"op": "incr", "path": "/total", "value": 5}],
)
print("o1 total after incr patch:", patched["total"])
$ docker run -d --name az-cosmos-patch-demo -e PROTOCOL=http mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-EN20261019
a8af5c9bf15764f1a2e35bc5e2057509da322f0e63b2872c247e2ab4aeac4725
$ until docker logs az-cosmos-patch-demo 2>&1 | grep -q 'System is now fully ready to accept requests'; do sleep 0.5; done
$ docker run --rm --network container:az-cosmos-patch-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-cosmos==4.17.1 2>&1 | tail -1 && python cosmos_patch.py"
[notice] To update, run: pip install --upgrade pip
o1 total before patch: 12
o1 total after incr patch: 17

incr adds its value to whatever /total currently is, server-side — there’s no window where the client has read 12 into memory and another writer could sneak a change in before this patch applies. A read-modify-upsert_item cycle doing total = read().total + 5 has exactly that window; patch_item with incr doesn’t.

A Stale ETag Makes replace_item Fail Loudly Instead of Overwriting Silently

from azure.cosmos import CosmosClient
from azure.cosmos.exceptions import CosmosAccessConditionFailedError
from azure.core import MatchConditions

ENDPOINT = "http://localhost:8081"
KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="

client = CosmosClient(ENDPOINT, credential=KEY)
container = client.get_database_client("storedb").get_container_client("orders")

stale = container.read_item(item="o1", partition_key="bob")
stale_etag = stale["_etag"]

# another process updates the item in between — this write has nothing to do with the
# etag we captured above, so the item's current _etag is now different from `stale_etag`
container.upsert_item({"id": "o1", "customer": "bob", "total": 999})

try:
    container.replace_item(
        item="o1",
        body={"id": "o1", "customer": "bob", "total": 1000},
        etag=stale_etag,
        match_condition=MatchConditions.IfNotModified,
    )
except CosmosAccessConditionFailedError as e:
    print("replace with stale etag ->", type(e).__name__, e.status_code)

fresh = container.read_item(item="o1", partition_key="bob")
retried = container.replace_item(
    item="o1",
    body={"id": "o1", "customer": "bob", "total": 1000},
    etag=fresh["_etag"],
    match_condition=MatchConditions.IfNotModified,
)
print("retry with current etag -> total:", retried["total"])
$ docker run --rm --network container:az-cosmos-patch-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-cosmos==4.17.1 2>&1 | tail -1 && python cosmos_etag_conflict.py"
[notice] To update, run: pip install --upgrade pip
replace with stale etag -> CosmosAccessConditionFailedError 412
retry with current etag -> total: 1000

The first replace_item fails because stale_etag was captured before the concurrent upsert_item ran — exactly the race optimistic concurrency is meant to catch. The fix isn’t to retry with the same stale_etag (that fails again forever) or to drop the etag condition entirely (that overwrites 999 without ever looking at it) — it’s reading the item again to get its current _etag, deciding the write is still correct, and retrying with that value.

Practice

1. Use the add and set patch operations to create and then overwrite a field in one patch_item call, then remove it with a second call.
from azure.cosmos import CosmosClient

ENDPOINT = "http://localhost:8081"
KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="

client = CosmosClient(ENDPOINT, credential=KEY)
container = client.get_database_client("storedb").get_container_client("orders")

container.patch_item(
    item="o0",
    partition_key="alice",
    patch_operations=[
        {"op": "add", "path": "/status", "value": "pending"},
        {"op": "set", "path": "/status", "value": "shipped"},
    ],
)
shipped = container.read_item(item="o0", partition_key="alice")
print("status after add+set:", shipped["status"])

container.patch_item(
    item="o0",
    partition_key="alice",
    patch_operations=[{"op": "remove", "path": "/status"}],
)
after_remove = container.read_item(item="o0", partition_key="alice")
print("has status after remove:", "status" in after_remove)
$ docker run --rm --network container:az-cosmos-patch-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-cosmos==4.17.1 2>&1 | tail -1 && python cosmos_patch_set_remove.py"
[notice] To update, run: pip install --upgrade pip
status after add+set: shipped
has status after remove: False

add on a path that doesn’t exist yet creates it; set (used here on a path that already exists) replaces its value; remove deletes the field entirely rather than setting it to null.

2. Call patch_item with a condition expression that's false and predict what that raises.
from azure.cosmos import CosmosClient
from azure.core.exceptions import HttpResponseError

ENDPOINT = "http://localhost:8081"
KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="

client = CosmosClient(ENDPOINT, credential=KEY)
container = client.get_database_client("storedb").get_container_client("orders")

before = container.read_item(item="o2", partition_key="alice")
print("o2 total before:", before["total"])

try:
    container.patch_item(
        item="o2",
        partition_key="alice",
        patch_operations=[{"op": "incr", "path": "/total", "value": 1000}],
        filter_predicate="FROM c WHERE c.total > 10000",
    )
except HttpResponseError as e:
    print("conditional patch with false predicate ->", type(e).__name__, e.status_code)

after = container.read_item(item="o2", partition_key="alice")
print("o2 total after (unchanged):", after["total"])
$ docker run --rm --network container:az-cosmos-patch-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-cosmos==4.17.1 2>&1 | tail -1 && python cosmos_patch_condition.py"
[notice] To update, run: pip install --upgrade pip
o2 total before: 55
conditional patch with false predicate -> CosmosAccessConditionFailedError 412
o2 total after (unchanged): 55

A filter_predicate turns the whole patch into a no-op-or-fail check: when the condition doesn’t match the item’s current state, Cosmos DB rejects the entire patch (a 412, the same status a failed ETag condition raises) rather than silently skipping just the operations that depend on it.

3. Call replace_item with no etag or match_condition at all, and confirm it overwrites unconditionally even though another write happened in between.
from azure.cosmos import CosmosClient

ENDPOINT = "http://localhost:8081"
KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="

client = CosmosClient(ENDPOINT, credential=KEY)
container = client.get_database_client("storedb").get_container_client("orders")

# another process updates o1 first, same as the etag example above
container.upsert_item({"id": "o1", "customer": "bob", "total": 2000})

# this replace carries no etag and no match_condition at all
unconditional = container.replace_item(
    item="o1", body={"id": "o1", "customer": "bob", "total": 3000}
)
print("unconditional replace -> total:", unconditional["total"])
$ docker run --rm --network container:az-cosmos-patch-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-cosmos==4.17.1 2>&1 | tail -1 && python cosmos_replace_no_etag.py"
[notice] To update, run: pip install --upgrade pip
unconditional replace -> total: 3000

No 412, no error of any kind — replace_item without an etag/match_condition pair is unconditional by default, the same way a plain upsert_item is. The optimistic-concurrency protection earlier in this lesson exists only because the code explicitly asked for it; a write that doesn’t pass an etag always wins the race, overwriting whatever was there regardless of whether it already changed since the last read.

Cleanup

$ docker rm -f az-cosmos-patch-demo
az-cosmos-patch-demo

Next: Azure Service Bus — sending and receiving messages through a queue, and what it means to settle one.

Frequently Asked Questions

What does patch_item do differently from read then upsert_item?
patch_item sends a small set of named operations (incr, set, remove, add, replace) that the server applies atomically to one item, without the client ever reading the current value first. A read-modify-upsert_item cycle has a gap between the read and the write where another writer's update can be silently overwritten; an incr patch operation can't be clobbered that way because the server does the increment itself.
What is a Cosmos DB ETag used for?
Every item has an _etag field that changes every time the item is written. Passing that value back as the etag/if-match condition on a later write makes the write conditional: it only succeeds if the item's current _etag still matches, which is how optimistic concurrency control works — without it, a write always just overwrites whatever is there, no matter how old the data the caller read was.
What should code do when replace_item raises a 412 from a stale ETag?
Re-read the item to get its current value and current _etag, decide whether the change still makes sense given what changed, and retry the write with the new _etag — not retry with the same stale _etag (which fails again) and not retry without any etag at all (which silently throws away whichever write loses the race).
Does patch_item need an ETag to protect against concurrent writes?
No — that's the point of using it instead of read-modify-write. The server applies the named operation (incr, in this lesson) directly against whatever the item's current value is at the moment the patch runs, so there's no window between a read and a write for another update to be lost; two concurrent incr patches on the same field both apply, in some order, rather than one silently overwriting the other.