Cosmos DB Transactional Batch: Keeping Multi-Item Writes Atomic Inside One Partition
An architecture note on Cosmos DB transactional batch: when partition-scoped atomicity fits, the smallest design that uses it, idempotent retry handling, and the signals that should push you to an outbox pattern instead.
19 Oct 2025, 12:57 UTC

If you need several Cosmos DB items to change together — say, an order record plus its line items plus a status log entry — you have exactly one primitive that does this atomically: the server-side transactional batch. It works only when every item in the batch shares the same logical partition key. This note lays out when that constraint is acceptable, the smallest design that satisfies it, and the signals that should push you toward a different pattern.
Requirements that justify a transactional batch
Use a transactional batch when all three of these hold:
- Every item you mutate in the operation lives under one logical partition key value.
- The writes must commit as a unit — partial success would corrupt the domain state.
- The payload is small: a bounded number of items, well under the per-request size limit.
If any of these fails, a batch is the wrong tool. In particular, a batch is not a distributed transaction. It gives you atomicity inside one partition scope, nothing more. There is no supported multi-partition ACID transaction in Cosmos DB; cross-partition consistency has to be built asynchronously (change feed, outbox, saga) or accepted as eventual.
The smallest suitable design
The design move is to align your aggregate boundary with the partition key. Pick the partition key so that everything that must be consistent together shares it. For an order domain, that might mean the order document, its line items, and an order-events document all carry orderId as the partition key value, distinguished by a type or id prefix.
A batch then looks like this (Azure Cosmos DB .NET SDK v3, run from your application process with a connection string or managed identity that has data-plane write access):
var pk = new PartitionKey(orderId);
TransactionalBatch batch = container.CreateTransactionalBatch(pk)
.CreateItem(new OrderDoc { Id = "order", OrderId = orderId, ... })
.CreateItem(new LineItemDoc { Id = "line-1", OrderId = orderId, ... })
.UpsertItem(new OrderEventDoc { Id = "evt-001", OrderId = orderId, ... });
using TransactionalBatchResponse resp = await batch.ExecuteAsync();
if (!resp.IsSuccessStatusCode)
{
// Nothing committed. resp failed at resp.FailedOperationIndex.
}All operations in the batch target the same partition key value; mixing keys throws a client-side error. Keep the batch small — the entire request must fit within the service's per-request size limit (a few MB), and large batches increase RU cost and the chance of throttling. Do not use batches as a bulk-import mechanism; that is what bulk mode is for, and bulk mode explicitly gives up atomicity.
Trust and data boundaries
The partition key is frequently also a tenancy boundary. A common mistake is co-locating multiple tenants under one partition key "so we can transact across them." Do not do this. Per-partition-key throughput caps, noisy-neighbor effects, and any future per-partition access scoping all assume the partition key is a trust boundary. If two tenants must update atomically, that is a cross-partition workflow and needs an asynchronous pattern, not a shared partition.
Operational checks before release
- RU charge per batch: read
resp.RequestChargein development and compare against your provisioned or serverless budget. Batch RU cost is roughly the sum of the individual writes plus coordination overhead. - Throttling (429s): a hot logical partition — one key receiving a large share of writes — will throttle even when total container throughput has headroom. Check the partition key distribution diagnostics in Azure Monitor for the container.
- Latency under your consistency level: strong consistency and multi-region writes change commit latency and conflict behavior materially. Measure with the production configuration, not the emulator defaults.
- Batch size and item count: log both; set an alert threshold well below the service limit so schema growth does not silently push you over.
Failure modes and retry semantics
When a batch fails — one operation conflicts, an item already exists, the request is too large — the entire batch rolls back. That part is clean. The subtle failure is the ambiguous commit: the client times out or the network drops after the server committed but before the response arrives. Retrying naively can then apply the effect twice (e.g., duplicate event documents, or a create that now fails with 409 and makes your code think the batch never ran).
Handle this with idempotency:
- Give items deterministic IDs derived from the operation (e.g.,
evt-{orderId}-{sequence}) so a retried create collides harmlessly instead of duplicating. - On retry, treat a 409 on a deterministic ID as "already applied" by reading the item and checking it matches the intended state.
- Alternatively, keep an application-level idempotency key document in the same partition and include its create in the batch; a 409 on that document tells you the batch already committed.
Verifying the behavior yourself
Two quick experiments, runnable against a test container or the emulator:
- Rollback check: build a batch of three creates where the third uses an ID that already exists. Execute, then query the partition. None of the three items should be present.
- Ambiguous-commit check: set an artificially short client timeout, execute a batch, catch the timeout, then read the partition. You will see the batch either fully committed or fully absent — never partial. Then verify your retry path handles the committed case without duplicating effects.
Also load-test your single hottest expected partition key before release; per-partition throughput ceilings are the constraint most likely to surprise you, and the exact limits are service- and configuration-dependent, so confirm them against current documentation for your API and throughput mode.
Conditions that should change the design
Revisit the batch approach when:
- A workflow starts needing atomicity across two partition keys — move to an outbox document in the owning partition plus a change feed processor that propagates asynchronously.
- The aggregate grows until batches approach request size limits — split the aggregate and accept eventual consistency between the parts.
- One partition key becomes a write hotspot — the consistency boundary is too coarse; shard the aggregate by a sub-key and coordinate asynchronously.
- Audit or downstream consumers need the changes anyway — the change feed may already give you the propagation you need, making the synchronous batch less necessary.
The rule of thumb: a transactional batch is the right answer exactly when the partition key and the consistency boundary are the same line. When they diverge, stop stretching the batch and switch to an asynchronous pattern.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.