9. Transactions and consistency
DynamoDB · 9. Transactions and consistencyDynamoDB 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.
The four levels of guarantee
Section titled “The four levels of guarantee” ① 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.
Conditional writes do most of the work
Section titled “Conditional writes do most of the work”# create-if-absent: the idempotent inserttable.put_item(Item=item, ConditionExpression="attribute_not_exists(PK)")
# optimistic lockingtable.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 databasetable.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.
Atomic counters
Section titled “Atomic counters”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.
TransactWriteItems
Section titled “TransactWriteItems”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 costConditionCheck 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.
Transaction conflicts
Section titled “Transaction conflicts” 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).Idempotency, which retries make mandatory
Section titled “Idempotency, which retries make mandatory”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 keytable.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.
Read consistency, gathered in one place
Section titled “Read consistency, gathered in one place” 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.Choosing the right level
Section titled “Choosing the right level” "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.
Beyond 100 items: sagas
Section titled “Beyond 100 items: sagas”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 statetable.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.
Choosing between locking strategies
Section titled “Choosing between locking strategies” 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 invariantsWhat to take from this chapter
Section titled “What to take from this chapter”- 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.
ADDgives a true atomic counter, with no read and no race — but it concentrates on one item, so shard it if it is hot.TransactWriteItemsis all-or-nothing over up to 100 items, 4 MB, one region, at double cost, with per-item conditions andConditionCheckfor items you do not modify.ClientRequestTokenmakes a transaction idempotent for about ten minutes.- Read
CancellationReasons: a failed condition is final, aTransactionConflictis 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_existsplus 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.