DEV Community

Cover image for DynamoDB Condition Expressions
DynoTable
DynoTable

Posted on • Originally published at dynotable.com

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. ConditionExpression runs server-side on the current item; a false result fails the write with ConditionalCheckFailedException.
  • It replaces read-then-write. No SELECT then UPDATE round 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. A rejected write bills WCUs for the size of the existing item it was checked against (minimum 1) — a failed create-if-absent costs 1 WCU.

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 attribute 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 partition key 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    = 0
Enter fullscreen mode Exit fullscreen mode

The 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)

Example Notes
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 >= :amt
Enter fullscreen mode Exit fullscreen mode

with :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:

Warning

clearedCents >= :amt compares the stored value, not the value you read
earlier. That's the whole point — the comparison happens after any concurrent
write has landed, so the guard is always evaluated against current truth.

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.

The ledger collection in DynoTable — the BALANCE item shows clearedCents above the account's transaction items.

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 >= :amt collapses "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 = :seen
Enter fullscreen mode Exit fullscreen mode

If 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
)
DynoTable's staging area runs this pattern for you — a
concurrent edit surfaces as a conflict to resolve, not a lost write.

Pitfalls and next steps

  • Names that collide with reserved words. status, size, name, and ~570 others are reserved. Alias them with ExpressionAttributeNames (#s = status) or the request is rejected with a ValidationException ('Attribute name is a reserved keyword'). The reserved-words checker takes your attribute names and hands back the alias map ready to paste.
  • A condition can't reference another item. It only sees the item being written. Cross-item invariants need TransactWriteItems with a per-action ConditionExpression, or a ConditionCheck against 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.

Top comments (0)