Ch. 15 · AWS

AWS DynamoDB Conditional Writes and Concurrency

How DynamoDB condition expressions stop lost updates: create-only puts, version checks, atomic counters with guards, and transactions.

~6 min readintermediateupdated Oct 7, 2026

DynamoDB doesn’t lock items for you between a read and a write. If two requests read the same item, each changes it in memory and each writes it back, the second write replaces the first and nobody gets an error. That’s the lost-update problem, and DynamoDB’s answer is the condition expression: a rule attached to a write that the service checks against the item as it’s stored at that moment, atomically with the write.

The race in slow motion

Take seat booking. Each seat is an item: pk = "seat#14C", with a holder and a version. Two API servers handle Alice and Bob at the same time. Both call GetItem, both see holder: null, both decide the seat is free and both call PutItem.

Here’s a small Python stand-in for a single-item table. Each put is atomic, and it can take a condition that is evaluated against the stored item at write time, which is the behaviour that matters here:

# A single-item "table" with DynamoDB-like semantics: each write is atomic,
# and an optional condition is checked against the stored item at write time.
class ConditionalCheckFailed(Exception): pass

table = {}

def put(key, item, condition=None):
    if condition and not condition(table.get(key)):
        raise ConditionalCheckFailed("The conditional request failed")
    table[key] = dict(item)

def get(key):
    return dict(table[key])

# Two API servers both read the seat, both decide to book it.
put("seat#14C", {"holder": None, "version": 1})
a, b = get("seat#14C"), get("seat#14C")

print("-- blind writes")
put("seat#14C", {**a, "holder": "alice", "version": a["version"] + 1})
put("seat#14C", {**b, "holder": "bob",   "version": b["version"] + 1})
print("final:", get("seat#14C"), "(alice was told she has the seat)")

print("-- optimistic locking on version")
put("seat#14C", {"holder": None, "version": 1})
a, b = get("seat#14C"), get("seat#14C")
for who, seen in (("alice", a), ("bob", b)):
    try:
        put("seat#14C", {**seen, "holder": who, "version": seen["version"] + 1},
            condition=lambda cur, v=seen["version"]: cur and cur["version"] == v)
        print(who, "booked it")
    except ConditionalCheckFailed as e:
        print(who, "->", e, "| reread:", get("seat#14C"))
python

Output from python3 cond.py:

-- blind writes
final: {'holder': 'bob', 'version': 2} (alice was told she has the seat)
-- optimistic locking on version
alice booked it
bob -> The conditional request failed | reread: {'holder': 'alice', 'version': 2}
Text

In the first run both writes succeed, both users get a confirmation, and Bob’s write quietly overwrote Alice’s. Notice that the version went to 2, not 3: each server computed it from what it read. In the second run, Bob’s write carries the condition “the version is still 1”, it isn’t, and he gets an error he can act on. The message is the one DynamoDB uses for ConditionalCheckFailedException.

Three conditions you’ll use constantly

Create only if it doesn’t exist

PutItem replaces an existing item with the same key by default. To make it a pure insert, add attribute_not_exists on the partition key:

aws dynamodb put-item \
  --table-name Users \
  --item '{"pk": {"S": "email#ana@example.com"}, "userId": {"S": "u_4821"}}' \
  --condition-expression "attribute_not_exists(pk)"
Terminal

This is how you enforce uniqueness on something that isn’t the user’s primary key: write a separate “lock” item keyed by the email. It’s also the core of an idempotency record. The condition is about this item, the one with this key; attribute_not_exists(pk) means “no item with this key exists”, not “no item in the table has this value”.

Update only if the version matches

For read-modify-write on bigger items, store a version number. Read the item, make your change, and write with a condition on the version you read, incrementing it in the same request:

aws dynamodb update-item \
  --table-name Seats \
  --key '{"pk": {"S": "seat#14C"}}' \
  --update-expression "SET holder = :who, version = version + :one" \
  --condition-expression "version = :seen" \
  --expression-attribute-values '{":who": {"S": "alice"}, ":one": {"N": "1"}, ":seen": {"N": "1"}}'
Terminal

This is optimistic locking: you assume conflicts are rare, don’t lock anything up front, and detect them at write time. The DynamoDB mapper in the AWS SDK for Java implements exactly this with a @DynamoDbVersionAttribute annotation.

Change a number only within bounds

For a counter, you often don’t need to read at all. An update expression can do arithmetic on the stored value, and the condition can guard it:

aws dynamodb update-item \
  --table-name Inventory \
  --key '{"pk": {"S": "sku#KB-104"}}' \
  --update-expression "SET stock = stock - :q" \
  --condition-expression "stock >= :q" \
  --expression-attribute-values '{":q": {"N": "2"}}' \
  --return-values UPDATED_NEW
Terminal

Twenty concurrent buyers can send this at once. Each is applied atomically against the current stock, and once stock drops below 2 the rest fail the condition instead of taking stock negative. Compared with read-then-write, it’s one round trip and no window for a race.

All of these examples are illustrative CLI calls; table names and values are made up and nothing was run against AWS.

Handling the failure properly

A condition failure is information, not an error to page someone about. What to do depends on the case:

  • Create-only failed: the thing already exists. For signup that’s “email already registered”. For an idempotency record it’s “already processed, return the stored result”.
  • Version check failed: someone changed the item. Reread and either reapply the user’s intent (if it still makes sense) or tell them the data changed. Retrying the same stale write in a loop never succeeds.
  • Bounds check failed: out of stock, or insufficient balance. Report it.

It helps to know why without a second read. Setting ReturnValuesOnConditionCheckFailure to ALL_OLD makes DynamoDB include the current item in the exception, so you can see that the version is now 7 or that the holder is already set.

Two things people don’t expect: a failed conditional write still consumes write capacity, and conditions can only look at the item being written. “Only insert this order if the customer’s account is active” involves two items, so a plain condition can’t express it.

When one item isn’t enough: transactions

TransactWriteItems groups up to 100 actions across items and tables, all or nothing. Each action can carry its own condition, and there’s a ConditionCheck action that checks an item without writing it. Moving a seat from one booking to another, or debiting one account and crediting another, fits here.

If any condition fails, nothing is written and you get TransactionCanceledException with a CancellationReasons list, one entry per action, so you can see which check failed. Transactions cost twice the capacity of standard writes and can be cancelled by conflicting concurrent transactions on the same items, so use them where you need multi-item atomicity and keep single-item conditions everywhere else.

Interview tip

If an interviewer asks “how do you get a unique constraint on a non-key attribute in DynamoDB?”, the answer is a transaction that puts the main item and a uniqueness item (pk = "email#...") together, each with attribute_not_exists(pk). Either both are created or neither is.

Interview practice: the wallet that went negative

A wallet service stores each user’s balance in a DynamoDB item. The withdraw endpoint calls GetItem, checks balance >= amount in code, then calls UpdateItem with SET balance = balance - :amt. Under load tests, some balances end up negative. A colleague suggests switching the update to a PutItem of the computed new balance. Explain the bug, why the suggestion makes it worse in a different way, and the fix.

Answer

The check and the write are separate requests, so the check is against a value that may be stale by the time the write lands. Two withdrawals of 80 both read a balance of 100, both pass the check in code, and both decrements are applied atomically by DynamoDB: 100 minus 80 minus 80 is -60. Each request was individually correct; the decision was made on old data.

Switching to PutItem of the computed balance hides the symptom without fixing it. Both requests compute 100 - 80 = 20 and write 20, so the balance never goes negative, but 160 has been paid out and only 80 recorded. That’s a lost update, which is harder to notice than a negative number.

The fix is to move the check into the write: UpdateItem with SET balance = balance - :amt and ConditionExpression: balance >= :amt. DynamoDB evaluates the condition against the stored balance at write time, so the second withdrawal fails with ConditionalCheckFailedException, which becomes the “insufficient funds” response. No read is needed beforehand. If the withdrawal must also record a ledger entry, I’d use TransactWriteItems with the guarded update plus a Put of the ledger item with attribute_not_exists(pk) keyed by the withdrawal ID. That also makes client retries safe: a repeated withdrawal ID cancels the transaction instead of debiting twice.

Keep going

More in AWS

read ✓AWS · mid

AWS DynamoDB Keys and Access Patterns

How to design DynamoDB partition and sort keys from your queries: listing access patterns first, composite sort keys, GSIs and hot partitions.

~6 min readread →
read ✓AWS · mid

AWS Lambda Retries and Idempotent Handlers

Who retries a failed Lambda invocation (the caller, the async queue or the event source) and how to write handlers that survive running twice.

~7 min readread →
esc