Intermediate5 min read

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

ConditionExpressionFilterExpression
PathWrites (Put/Update/Delete)Reads (Query/Scan)
Effect on failureRejects the whole writeDrops the item from results
SeesThe current item, pre-writeEach candidate item, post-read
CostFailed write still billsFiltered 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    = 0

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)

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

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:

Build your request
Generated code
new PutItemCommand({
  "TableName": "AuditLog",
  "Item": {
    "pk": {
      "S": "TENANT#acme"
    },
    "sk": {
      "S": "EVENT#2026-06-24T10:00:00Z"
    },
    "action": {
      "S": "login"
    }
  },
  "ConditionExpression": "attribute_not_exists(#cond0)",
  "ExpressionAttributeNames": {
    "#cond0": "pk"
  }
})

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.
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

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)

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 your expression silently fails to parse.
  • 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.

Updated