Keeping Denormalized Cosmos DB Documents Consistent with TransactionalBatch
Denormalized Cosmos DB models often require two documents to change together. TransactionalBatch gives you atomic, all-or-nothing writes within one logical partition — here's how it works, a .NET example, and where it falls down.
11 Jun 2026, 20:40 UTC

The problem: two writes, one truth
Denormalization is the standard modeling advice for Azure Cosmos DB for NoSQL: duplicate data across documents so reads stay cheap and single-partition. The catch shows up on the write path. If placing an order means creating an order document and also updating an orderCount field on the customer document, you now have two writes that must succeed or fail together. A crash, throttling error, or conflict between them leaves your data lying to you.
Cosmos DB has no multi-document transactions across partitions, but it does offer TransactionalBatch: a group of point operations executed atomically within a single logical partition. Either every operation in the batch is applied, or none are. For many denormalized models, that is exactly the consistency primitive you need — without stored procedures.
What TransactionalBatch actually guarantees
A transactional batch bundles Create, Read, Replace, Upsert, Delete, and Patch operations into one request. The guarantees and limits that matter for design:
- Single logical partition only. Every operation must target items sharing the same partition key value. Cross-partition atomicity is not supported — this is the constraint that should drive your partition key choice.
- All-or-nothing. If any operation fails (a conflict, a failed precondition, an invalid id), the entire batch rolls back and you get the failing status code back.
- Size cap. Batches are limited to roughly 100 operations per execution (confirm against your SDK version). This is a tool for small, related write groups, not bulk loading.
- Normal billing. The batch consumes request units (RU/s) like the equivalent individual operations. Atomicity is free of extra charge, but it is not a cost optimization either.
Worked example: order + customer summary
Assume a container partitioned by /customerId, holding both customer documents and order documents. When an order is placed, you want both writes to land atomically. Using the .NET SDK (v3; check your installed version for exact API shape):
// Run in your application process; needs an identity/key with
// data-plane write access to the container.
PartitionKey pk = new PartitionKey("customer-42");
TransactionalBatch batch = container.CreateTransactionalBatch(pk)
.CreateItem(new Order {
id = "order-987",
customerId = "customer-42",
total = 129.99
})
.PatchItem<Customer>("customer-42", new[] {
PatchOperation.Increment("/orderCount", 1),
PatchOperation.Set("/lastOrderId", "order-987")
});
using TransactionalBatchResponse response = await batch.ExecuteAsync();
if (!response.IsSuccessStatusCode)
{
// Nothing was applied. response.StatusCode and per-op results
// tell you which operation failed and why.
}Because both documents live under customerId = "customer-42", the batch is legal. If the customer document was deleted concurrently or a patch precondition fails, the order insert is rolled back too — you never get an order without a matching summary update.
You can also layer optimistic concurrency on top: pass an ETag precondition (e.g., IfMatchEtag) on an individual operation, and a concurrent modification of that document fails the whole batch. That gives you atomicity and lost-update protection in one round trip.
Verifying the behavior yourself
Do not take the rollback semantics on faith — they are easy to test:
- Write a small program that batches an insert plus a patch on two documents sharing a partition key.
- Force a failure — for example, patch a document id that does not exist, or set an ETag precondition you know is stale.
- Read both documents afterward and confirm neither change persisted.
- Inspect the request diagnostics (RU charge and status code) on the failed batch to see the cost of an aborted batch and confirm the all-or-nothing status.
Also check the API reference or changelog for the SDK version pinned in your project — operation limits and supported operation types have evolved across releases.
Trade-offs and when not to use it
The single-partition restriction is the real design constraint. If the writes you need to keep consistent cannot share a partition key, TransactionalBatch is off the table; you need a saga or outbox pattern with compensating actions instead, and you should accept eventual consistency between the documents.
Two more practical cautions. First, heavy batch traffic against one logical partition concentrates RU consumption there and can produce a hot partition — the 100-operation cap does not protect you from throughput saturation. Second, server-side stored procedures offer similar atomic semantics and predate batch, but they are JavaScript-based, harder to test and version, and operationally heavier; prefer TransactionalBatch unless you need logic the batch API cannot express.
Closing takeaway
If your denormalized model colocates related documents under one partition key, TransactionalBatch gives you atomic multi-document writes with plain SDK calls and no server-side code. The actionable step: audit your write paths for any place where two documents change together, check whether they share a partition key, and if they do, wrap them in a batch — then prove the rollback behavior with the forced-failure test above before you rely on it in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.