DynamoDB Transactions vs Conditional Writes: When Each Is the Right Tool
DynamoDB has two atomicity primitives, and they solve overlapping but distinct problems.
Conditional writes (ConditionExpression on PutItem, UpdateItem, DeleteItem) let you make a single-item write conditional on the item’s current state. Free, fast, the right tool 80% of the time.
Transactions (TransactWriteItems, TransactGetItems) let you read or write up to 100 items atomically. Powerful, but 2x the WCU cost and constrained in ways that surprise people.
The decision boils down to this: do you need atomicity across multiple items, or does atomicity within a single item suffice?
Conditional writes: single-item atomicity for free
A conditional write executes only if a condition on the item’s current state holds. If the condition fails, the write doesn’t happen and you get a ConditionalCheckFailedException.
// Decrement inventory only if there's enough in stock
await ProductEntity.update({ productId })
.add({ stock: -3 })
.where(({ stock }, { gte }) => gte(stock, 3))
.go()
That single call does:
- Read the current
stockvalue - Check if
stock >= 3 - If yes, atomically decrement by 3 and return the new value
- If no, fail with
ConditionalCheckFailedException
All atomic. No race condition. Cost: 1 WCU (same as a normal write).
This is enough for most “is it safe to write” cases:
- Optimistic locking:
where(version === expectedVersion)thenset(version + 1) - Idempotency check:
attribute_not_exists(pk)ensures you don’t double-create (see idempotency keys) - Inventory decrement:
where(stock >= quantity)prevents overselling - State transitions:
where(status === "pending")ensures you only confirm pending orders - Counter bounds:
where(views < maxViews)for usage caps
The pattern is always: “I want to write this, but only if the item still looks the way I expect.” That’s exactly what ConditionExpression does.
What conditional writes can’t do
Conditional writes are scoped to a single item. They can’t:
- Check one item’s state and write to a different item atomically
- Write multiple items as a single all-or-nothing unit
- Read several items in a snapshot view
If your atomicity requirement spans more than one item, you need transactions.
Transactions: multi-item atomicity, at a cost
TransactWriteItems lets you submit up to 100 writes (Put/Update/Delete/ConditionCheck) as a single atomic operation. Either all succeed, or none do.
// Transfer money between two accounts atomically
await client.send(new TransactWriteItemsCommand({
TransactItems: [
{
Update: {
TableName,
Key: { pk: "ACCOUNT#alice", sk: "#METADATA" },
UpdateExpression: "ADD balance :amt",
ConditionExpression: "balance >= :amt",
ExpressionAttributeValues: { ":amt": -100 },
},
},
{
Update: {
TableName,
Key: { pk: "ACCOUNT#bob", sk: "#METADATA" },
UpdateExpression: "ADD balance :amt",
ExpressionAttributeValues: { ":amt": 100 },
},
},
],
}))
Either both updates succeed or neither does. No way to leave money in limbo. No way for Alice’s balance to go negative if the condition fails.
What it costs you:
- 2x WCU per item. A 1KB write that normally costs 1 WCU costs 2 WCUs in a transaction.
- 100-item limit, or 4MB total transaction size, whichever comes first.
- Throughput limit. Transactions are limited to a fraction of overall table throughput; very high transaction rates can throttle even when the underlying writes wouldn’t.
- Cross-table is allowed but rare. Transactions can span multiple tables in the same region; few designs need this.
- No “transactional read after write.” Transactions don’t give you a transaction handle that holds across multiple round trips. They’re one-shot - submit the whole batch, get the whole result.
What transactions are uniquely good at
Multi-entity invariants are the primary use case - anything where the consistency property spans multiple items:
- Bank-style transfers (debit + credit must be atomic)
- “Booking and seat reservation” (booking insert + seat-status update)
- Order placement with inventory decrement (order item create + per-product stock decrement)
- Tag application (tag junction insert + counter increment on the tagged article)
Multi-item idempotency is another strong fit. Insert an idempotency record AND the actual record in the same transaction. If the idempotency record already exists, the transaction fails and nothing is written. The idempotency keys post walks through this.
Uniqueness across attributes is one of the cleanest uses. DynamoDB can’t enforce “username is globally unique” with a primary key alone (the PK is the user ID). The trick: write two items in a transaction - the user record AND a USERNAME#<name> / #LOCK item with attribute_not_exists. If the username is taken, the transaction fails and the user record is never created.
await client.send(new TransactWriteItemsCommand({
TransactItems: [
{
Put: {
TableName,
Item: marshall({
pk: `USER#${userId}`,
sk: "#METADATA",
username, email, ...
}),
ConditionExpression: "attribute_not_exists(pk)",
},
},
{
Put: {
TableName,
Item: marshall({
pk: `USERNAME#${username}`,
sk: "#LOCK",
userId,
}),
ConditionExpression: "attribute_not_exists(pk)",
},
},
],
}))
Either both items are written (user created, username locked) or neither (username was taken, user not created). This is one of the cleanest uses of transactions.
When NOT to use a transaction
Most “atomic write” requirements don’t actually need a transaction.
Single-item state transitions don’t need transactions - conditional writes are sufficient and free.
For read-then-write at the application layer, use a conditional write with version or expectedAttribute checks. Transactions don’t give you anything more.
For batch insertions where you don’t need atomicity (one failure shouldn’t roll back the rest), use BatchWriteItem. It’s faster and cheaper. BatchWriteItem is unrelated to transactions despite the name; failures are independent.
The most wasteful pattern is “just in case” atomicity. I see this regularly: an engineer wraps a 5-write sequence in a transaction even though only the first two writes care about each other. You’re paying 2x WCU on every write for a guarantee you don’t need. Be specific about what has to be atomic.
The cost math
For a workload doing 1,000 writes/second of 1KB items:
- All non-transactional: 1,000 WCU/sec → ~$2,200/month at on-demand pricing
- All transactional: 2,000 WCU/sec → ~$4,400/month
For most apps, only ~5-10% of writes actually need transactional guarantees. Wrapping everything in transactions doubles your write cost for no correctness gain. Be deliberate about which writes need it.
If you’re in a design phase, list every multi-item invariant explicitly. Each one is a candidate for transactions. Everything else should be conditional writes or plain writes.
Common patterns
”Create if not exists”
Conditional write. Don’t reach for a transaction.
await UserEntity.create({ userId, email, ... })
.where(({ userId: u }, { notExists }) => notExists(u))
.go()
”Update only if I’m the latest version”
Conditional write with optimistic locking.
await ArticleEntity.update({ articleId })
.set({ title: newTitle, version: expectedVersion + 1 })
.where(({ version }, { eq }) => eq(version, expectedVersion))
.go()
”Decrement counter, fail if negative”
Conditional write.
await StockEntity.update({ productId })
.add({ available: -quantity })
.where(({ available }, { gte }) => gte(available, quantity))
.go()
”Place order and decrement inventory across multiple products”
Transaction. The order placement and the per-product decrements have to be atomic - you can’t have an order that says “shipped two of product A and three of product B” if product B was actually out of stock.
”Globally unique username”
Transaction. Write the user item and the username-lock item together, both with attribute_not_exists conditions.
”Move item from queue A to queue B”
Transaction. Delete the item from queue A and insert into queue B atomically. Otherwise a failure between the two leaves the item duplicated or lost.
Common bugs
Transaction failure isn’t a single error. TransactWriteItems failure is reported per-item: you get a CancellationReasons array showing which item caused the failure. Surface this to the user, not a generic “transaction failed.”
Don’t treat transaction throughput like single-write throughput. Transactional writes consume 2x WCUs and have additional throttling characteristics. Don’t size capacity assuming all writes are non-transactional if some are.
The most common antipattern is wrapping a single conditional write in a transaction “for safety.” It’s not safer - it’s more expensive and more complex.
Watch for stale reads followed by transactions. If you read an item, modify it in code, and then submit a transaction without a ConditionExpression on the original item, you’ve created a race. Always include the condition that the item still looks the way you expected.
A “transaction across 50 items” is sometimes a sign that the schema is wrong. If 50 items have to be atomically consistent, maybe they should be one item, or one item with a list attribute. Check for over-decomposition.
Decision rule
Conditional writes for single-item invariants, transactions for multi-item invariants, and nothing fancier than that.
In practice:
- Default to plain writes.
- Add a
ConditionExpressionif the write has a precondition (state, version, existence). - Wrap in
TransactWriteItemsonly if the precondition spans multiple items, or if you need multiple items written atomically together. - Don’t reach for transactions to “be safe.” They’re a specific tool, not a default.
The cost of getting this right is small attention at design time. The cost of getting it wrong is 2x your write bill, and a system that’s harder to reason about than it needs to be.
Most of the patterns at singletable.dev flag where transactions are required vs optional - the e-commerce orders, marketplace, and event sourcing patterns all have explicit “this needs to be in a transaction” callouts. The rest gets by on conditional writes alone.