Skip to content

9. Transactions and consistency

DynamoDB has real ACID transactions across items, tables and even accounts’ worth of scale — and most well-built applications on it use transactions rarely. This chapter explains both halves of that sentence.

① SINGLE-ITEM WRITE always atomic. an item
is written completely
or not at all. free.
② CONDITIONAL WRITE atomic compare-and-set
on one item. the
workhorse. (ch 4)
③ TRANSACTWRITEITEMS all-or-nothing across
up to 100 items.
costs double.
④ APPLICATION-LEVEL SAGA multi-step workflows
with compensation, when
100 items or one region
is not enough.

The skill is choosing the lowest level that is actually sufficient, because each step up costs capacity, latency and complexity.

# create-if-absent: the idempotent insert
table.put_item(Item=item,
ConditionExpression="attribute_not_exists(PK)")
# optimistic locking
table.update_item(
Key=key,
UpdateExpression="SET #s = :new, version = :next",
ConditionExpression="version = :current",
ExpressionAttributeValues={":new": "shipped", ":next": 8, ":current": 7},
)
# a business invariant, enforced by the database
table.update_item(
Key=key,
UpdateExpression="SET balance = balance - :amt",
ConditionExpression="balance >= :amt",
ExpressionAttributeValues={":amt": Decimal("20.00")},
)
WHY THIS IS ENOUGH SO OFTEN
A condition is evaluated on the LEADER, inside the
write, with no read-then-write race. If the
invariant involves ONE item, you do not need a
transaction — you need a condition.

Failure returns ConditionalCheckFailedException, and still consumes write capacity. Treat it as an expected outcome in your code, not an exception to log at error level.

table.update_item(Key=key,
UpdateExpression="ADD viewCount :one",
ExpressionAttributeValues={":one": 1})

No read, no version, no conflict. ADD is applied at the leader. For counters that do not need to be read-your-write exact, this is the cheapest correct mechanism in the product — but remember it lands on one item in one partition, so a global counter is a hot key by construction (chapter 3). Shard it if it is hot.

client.transact_write_items(TransactItems=[
{"Update": {"TableName": "AppData",
"Key": {"PK": {"S": "ACCOUNT#A"}, "SK": {"S": "BALANCE"}},
"UpdateExpression": "SET balance = balance - :amt",
"ConditionExpression": "balance >= :amt",
"ExpressionAttributeValues": {":amt": {"N": "20"}}}},
{"Update": {"TableName": "AppData",
"Key": {"PK": {"S": "ACCOUNT#B"}, "SK": {"S": "BALANCE"}},
"UpdateExpression": "SET balance = balance + :amt",
"ExpressionAttributeValues": {":amt": {"N": "20"}}}},
], ClientRequestToken="transfer-9f2c-…")
GUARANTEES
• all items commit, or none do
• each item may carry its own condition
• serializable isolation with respect to other
transactions
• ClientRequestToken makes the whole transaction
IDEMPOTENT for ~10 minutes
LIMITS
• 100 items, 4 MB total
• no two operations on the SAME item in one
transaction
• single region only — global tables replicate
the results, not the transaction (ch 11)
• 2× capacity cost

ConditionCheck is the fourth operation type and is often the point of the whole call:

{"ConditionCheck": {"TableName": "AppData",
"Key": {"PK": {"S": "USER#42"}, "SK": {"S": "PROFILE"}},
"ConditionExpression": "accountStatus = :active",
"ExpressionAttributeValues": {":active": {"S": "ACTIVE"}}}}

It asserts something about an item you are not modifying — “the account is still active” — inside the same atomic step.

TransactionCanceledException
CancellationReasons: [
{Code: "ConditionalCheckFailed"}, ← your rule
{Code: "TransactionConflict"}, ← contention
{Code: "ProvisionedThroughputExceeded"}
]
READ THE REASONS ARRAY. It is positional, matching
your TransactItems, and it is the difference between
"the balance was too low" (do not retry) and
"another transaction touched this item" (retry).

The SDK retries. Networks drop responses. So assume every write may execute twice:

NATURALLY IDEMPOTENT NOT IDEMPOTENT
PutItem with a fixed item ADD counter :one
SET attr = value list_append
DeleteItem "append to history"
Transact with a
ClientRequestToken
# the standard pattern: an idempotency key as the item key
table.put_item(
Item={"PK": f"IDEMPOTENCY#{request_id}", "SK": "LOCK",
"expiresAt": int(time.time()) + 86400},
ConditionExpression="attribute_not_exists(PK)")
# → succeeds once. a duplicate request fails the condition,
# and the caller returns the stored result instead.

Pair it with TTL (chapter 12) so the idempotency records clean themselves up for free.

TABLE READ, eventually consistent may be stale by
milliseconds
TABLE READ, strongly consistent sees every
acknowledged write
GSI READ ALWAYS eventually
consistent (ch 7)
TRANSACTGETITEMS serializable
snapshot across
up to 100 items
GLOBAL TABLES, other region eventually
consistent,
last-writer-wins
(ch 11)
THE TRAP THAT CATCHES EVERYONE
write item ──► immediately query a GSI for it
→ not there yet. the index is
asynchronous.
FIX: read the base table by key for read-your-own-
writes; use indexes for other people's data.
"only one signup per email" conditional write
on a unique item
"decrement stock, never below 0" conditional write
"transfer between two accounts" transaction
"create order + reserve stock +
write outbox event" transaction
"order → payment → shipping,
across services" saga, with a
stream-driven
state machine (ch 10)

The uniqueness case is worth spelling out, because it is the most common: DynamoDB has no unique constraint on a non-key attribute. You get uniqueness by making the value a key:

client.transact_write_items(TransactItems=[
{"Put": {"TableName": "AppData",
"Item": {"PK": {"S": "EMAIL#a@b.c"}, "SK": {"S": "UNIQUE"}},
"ConditionExpression": "attribute_not_exists(PK)"}},
{"Put": {"TableName": "AppData",
"Item": {"PK": {"S": "USER#42"}, "SK": {"S": "PROFILE"},
"email": {"S": "a@b.c"}}}},
])

One transaction, two items: the claim on the email and the user itself, atomically.

Transactions stop at 100 items, one region and one request. Anything larger, longer or spanning services becomes a saga: a sequence of local transactions, each with a compensating action if a later step fails.

PLACE ORDER
① reserve stock (conditional write)
② take payment (external service)
③ create shipment (conditional write)
IF ③ FAILS
compensate ② — refund
compensate ① — release the stock
HOW IT IS BUILT HERE
• each step writes its outcome to the order item
as a STATE attribute, conditionally
• the STREAM triggers the next step (ch 10)
• the state attribute makes every step
idempotent: "if state is already PAID, skip"
• a TTL item scheduled at step ① becomes the
timeout, firing a compensation if the saga
never completed (ch 10)
# advance the state machine, only from the expected state
table.update_item(
Key=order_key,
UpdateExpression="SET #s = :next, updatedAt = :t",
ConditionExpression="#s = :expected",
ExpressionAttributeNames={"#s": "state"},
ExpressionAttributeValues={":next": "PAID", ":expected": "RESERVED",
":t": now()})

That conditional update is the concurrency control for the whole workflow: two workers processing the same stream record cannot both advance the state, because only one will match :expected.

OPTIMISTIC (version attribute)
✓ no contention cost when conflicts are rare
✓ one round trip
✗ the caller must handle and retry the failure
→ the default for user-edited records
CONDITIONAL INVARIANT (balance >= :amt)
✓ no version to manage; the rule IS the check
→ the default for counters and stock
PESSIMISTIC (a lock item with TTL)
✓ prevents wasted work on long operations
✗ you must handle the holder crashing — which is
what the TTL is for
→ long-running imports, exclusive jobs
TRANSACTION
✓ genuine multi-item atomicity
✗ 2× cost, 100-item limit, conflict cancellations
→ money movement, multi-entity invariants
  • Four levels: single-item atomicity, conditional writes, TransactWriteItems, and application sagas. Use the lowest that suffices.
  • Conditional writes are the workhorse — compare-and-set, optimistic locking and invariants enforced on the leader. They cost capacity even when they fail.
  • ADD gives a true atomic counter, with no read and no race — but it concentrates on one item, so shard it if it is hot.
  • TransactWriteItems is all-or-nothing over up to 100 items, 4 MB, one region, at double cost, with per-item conditions and ConditionCheck for items you do not modify.
  • ClientRequestToken makes a transaction idempotent for about ten minutes.
  • Read CancellationReasons: a failed condition is final, a TransactionConflict is retryable, and heavy contention on one item cannot be fixed with capacity.
  • Assume every write may run twice. Use idempotency-key items with attribute_not_exists plus TTL cleanup.
  • GSIs are always eventually consistent — never read your own write from an index.
  • There is no unique constraint on non-key attributes; get uniqueness by making the value a key, claimed in a transaction.

Next: streams and event-driven patterns — change capture, Lambda triggers, and the outbox that keeps other systems in step.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source