DynamoDB Condition Expressions
A condition expression is a predicate DynamoDB evaluates on the existing item
before it commits your write. If the predicate is false, the write is
rejected and nothing changes. It is the closest thing DynamoDB has to a
WHERE clause on a write — and the only safe way to enforce an invariant.
How do DynamoDB condition expressions work?
A condition expression is a predicate DynamoDB evaluates server-side against the current item before committing a write. If it's true, the write proceeds; if false, the write is rejected with ConditionalCheckFailedException and nothing changes. It folds the check and the mutation into one atomic operation, so concurrent callers can't race a stale read.
- It's a guard, not a filter.
ConditionExpressionruns server-side on the current item; a false result fails the write withConditionalCheckFailedException. - It replaces read-then-write. No
SELECTthenUPDATEround trip — the check and the mutation are one atomic operation, so two callers can't race. - It's free to reject, not free to run. A failed conditional write still consumes write capacity. The guarantee costs the same as the write it blocks.
Coming from SQL, you'd read the row, check it in app code, then update. In DynamoDB that gap between read and write is a data-corruption bug waiting for a concurrent caller. The condition expression closes the gap.
Where they apply
You attach a ConditionExpression to PutItem, UpdateItem, DeleteItem, and
each action inside TransactWriteItems. It is not part of Query or Scan
— those use FilterExpression, which is a different thing on the read path.
That distinction trips people up, so be precise:
ConditionExpression | FilterExpression | |
|---|---|---|
| Path | Writes (Put/Update/Delete) | Reads (Query/Scan) |
| Effect on failure | Rejects the whole write | Drops the item from results |
| Sees | The current item, pre-write | Each candidate item, post-read |
| Cost | Failed write still bills | Filtered items are still billed for the read |
Both run server-side. The difference is what "false" does: a condition aborts a mutation; a filter just hides a row you already paid to read. (AWS: Condition Expressions)
The functions you'll actually use
The condition language is small. The workhorses:
attribute_exists(path)/attribute_not_exists(path)— does this exist on the item? The classic idiom for "create only if absent" / "update only if present".- Comparators —
=,<>,<,<=,>,>=— against a value or another attribute. attribute_type,begins_with,contains,size— type and string/set checks.BETWEEN … AND …,IN (…)— range and membership.AND,OR,NOT, parentheses — to combine the above.
attribute_not_exists on the is the canonical way to make
PutItem behave like an insert that won't clobber an existing item — DynamoDB
has no separate "insert" op, so the condition is the insert semantics.
(AWS: Comparison Operator and Function Reference)
A worked example: guarding a ledger against overdraft
Take a banking ledger. Each account is one item:
PK = "ACCT#a7f3"
SK = "BALANCE"
clearedCents = 50000
holdCents = 0The invariant: a debit must never push the available balance below zero, and you must never debit an account that doesn't exist. Two rules, both enforceable in the write itself.
The wrong way (the footgun)
GetItem ACCT#a7f3 / BALANCE → clearedCents = 50000
if (50000 >= 30000) ... ← app-side check
UpdateItem SET clearedCents = 20000
Between the GetItem and the UpdateItem, a second debit can read the same
50000, pass its own check, and write too. Both succeed; the account goes
negative. This is a read-modify-write race, and no amount of app-side validation
fixes it — the check and the write are separate operations.
The right way
Fold the check into the write. Debit 30000 cents, conditional on the account existing and holding enough:
UpdateItem ACCT#a7f3 / BALANCE
SET clearedCents = clearedCents - :amt
ConditionExpression:
attribute_exists(PK) AND clearedCents >= :amtwith :amt = 30000. If the balance is too low, or the item was never created,
DynamoDB rejects the write with ConditionalCheckFailedException and the balance
is untouched. The concurrent debit either sees the original balance and is
checked against it, or sees the updated one — never a stale read it acted on.
You can build and copy the exact expression — names, values, and all — with the
DynamoDB expression builder instead of
hand-assembling the ExpressionAttributeValues map.
Try it right here — this builder is preset to a guarded PutItem
(attribute_not_exists) so you can read the generated ConditionExpression:
Inspecting the guard in DynoTable
When a conditional write fails, you want to see the item's real state, not guess
at it. Pull the account item up and read clearedCents directly.

Read the rejection, don't retry blindly
ConditionalCheckFailedException is not a transient error — retrying the same
write changes nothing. It means a business rule fired: insufficient funds,
duplicate create, stale version. Surface it as a domain outcome, not an infra
blip.
Two things make failures debuggable:
ReturnValuesOnConditionCheckFailure: ALL_OLD— DynamoDB returns the current item alongside the failure, so you can show "balance was 20000, you asked for 30000" without a second read. (AWS: Working with Items)- Distinguishing the two failure reasons.
attribute_exists(PK) AND clearedCents >= :amtcollapses "no account" and "no funds" into one exception. If callers need to tell them apart, split into two writes or inspect the returned item.
Optimistic locking is the same trick
The version-number pattern is just a condition expression wearing a different
hat. Store a version attribute; every write asserts the version you read and
bumps it:
UpdateItem ACCT#a7f3 / BALANCE
SET clearedCents = :new, version = :next
ConditionExpression: version = :seenIf another writer moved first, version = :seen is false, the write is rejected,
and you re-read and retry. This is how DynamoDB does concurrency control without
locks — assert what you saw, fail if it moved. (AWS: Optimistic Locking with
Version Number)
Pitfalls and next steps
- Names that collide with reserved words.
status,size,name, and ~570 others are reserved. Alias them withExpressionAttributeNames(#s = status) or your expression silently fails to parse. - A condition can't reference another item. It only sees the item being
written. Cross-item invariants need
TransactWriteItemswith a per-actionConditionExpression, or aConditionCheckagainst a sentinel item. - Failed writes still cost WCUs. A guard that rejects 90% of the time still bills for those rejections. Cheap insurance, but not free.
For modeling the keys these guards run against, see single-table design and Query vs Scan. When you're ready to issue conditional writes against real data, download DynoTable and run them against your own tables.


