DynamoDB ConditionalCheckFailedException: Why and How to Fix
ConditionalCheckFailedException means DynamoDB refused your write because the item's real state didn't match what you told it to expect — and here's the part almost nobody expects: the most common "fix" people apply, deleting the ConditionExpression so the error stops appearing, is the one change that turns a protected table into an unprotected one. The error goes away. So does the guard rail that was stopping lost updates, duplicate records, and silent overwrites.
What ConditionalCheckFailedException actually is
DynamoDB's own error reference describes it in one sentence: a condition specified in the operation failed to be evaluated. That's deliberately dry, so let's translate it. Three of DynamoDB's write operations — PutItem (create or replace an item), UpdateItem (change specific attributes), and DeleteItem (remove an item) — all accept an optional ConditionExpression. Think of a condition expression as a bouncer standing at the door of the write: "only let this write happen if X is true." If X is true, the write goes through as normal, in a single atomic step with no gap for another request to sneak in between the check and the write. If X is false, DynamoDB throws out the whole request and hands you back ConditionalCheckFailedException, with the message text "The conditional request failed."
The AWS SDK exceptions for this error, across every language, carry an optional Item field: the actual item that was in the table at the moment the check failed. Most SDKs don't populate it unless you explicitly ask for it, which is the single biggest reason this error frustrates people — by default you get told that you lost, not why.
A jargon note before anything else, because this post promises not to send you to another tab: an "atomic" operation is one that either completes entirely or doesn't happen at all — there's no possible outcome where it's half-done. Think of it like a bank transfer between two accounts: money leaves one and arrives in the other as a single indivisible event, never a state where it's left one account but not yet reached the other. DynamoDB's condition-check-then-write is atomic in exactly that sense: nothing else can slip in between the check and the write, on that one item.
Jake's phone shop runs its trade-in ledger on a table like this, and he's noticed something odd: the error only ever shows up on Friday afternoons, right when the commission payouts post and three registers ring up trade-ins within the same minute. That's not a coincidence, and it's not a bug either — it's the table doing exactly what it's supposed to do the one time of the week it's actually being asked to handle real concurrent traffic. The other six days, nothing touches the same item twice in the same second, so the protection just sits there quietly, unused and unnoticed.
The three things a condition expression is actually protecting
Every ConditionalCheckFailedException you will ever see traces back to one of three underlying protections. Knowing which one you're dealing with tells you immediately whether the right move is to retry, to tell the user, or to leave the item alone entirely. Skipping this step is the reason so much advice about this error is generic and unhelpful — "just catch it and retry" is correct for exactly one of the three, and actively wrong for the other two.
| Protection | Typical condition used | What a failure means |
|---|---|---|
| Duplicate / overwrite prevention | attribute_not_exists(pk) |
An item with that exact key already exists — you tried to create something that's already there. |
| Lost-update prevention (optimistic locking) | #version = :expectedVersion |
Someone else wrote to this item after you read it — your copy is stale. |
| Business-rule enforcement | #status = :active, stock > :zero |
The item is real and current, but it's not in a state where this action is allowed. |
🙋♂️ Jake's Reality Check
"Ethan, I'm not being dense here — if the item didn't change, why did my update just get rejected? Nobody else touches this table but my one Lambda function."
Ethan's answer, no hedging: "'One function' isn't the same as 'one copy of it running.' The moment two customers hit checkout in the same second, AWS can spin up two separate copies of that same function, and each one reads the same stock count thinking it's the only writer in the room. I've stopped calling that a race condition in front of clients — I just call it Tuesday. It's not malicious concurrent users you're guarding against. It's ordinary traffic."
Protection 1: stopping a write from silently overwriting an item
DynamoDB's default behavior for PutItem is blunt: if you write an item with a key that already exists in the table, the old item is gone, replaced entirely by the new one, no warning, no confirmation. There's no "are you sure" dialog in an API call. If your table's partition key is a customer email address and you PutItem a signup form twice — once from a slow retry, once from the original request — the second write wins and the first is gone, attributes and all.
The fix is one function: attribute_not_exists(). AWS's own developer guide walks through this exact pattern — you attach a condition expression that names the table's partition key attribute, and because every item in the table is required to have that attribute, the function only evaluates true when no matching item exists yet.
- Decide which attribute the condition checks — almost always the table's partition key, since every item must have one.
- Add
ConditionExpression: "attribute_not_exists(Id)"(swapIdfor your actual key name) to thePutItemcall. - Run the write. If no item with that key exists, it succeeds normally.
- If an item does exist, DynamoDB rejects the write and returns ConditionalCheckFailedException instead of silently replacing the item.
AWS's own CLI documentation shows the exact shape of this:
aws dynamodb put-item \
--table-name ProductCatalog \
--item file://item.json \
--condition-expression "attribute_not_exists(Id)"One detail that trips people up on their first composite-key table: if your table has both a partition key and a sort key, DynamoDB still evaluates the whole condition against the single item identified by both key values together, not against "does anything with this partition key exist anywhere." Testing attribute_not_exists() on a key attribute is only true when no item with that exact partition-key-plus-sort-key combination exists yet. That's usually exactly what you want — it lets you create item USER#42 / ORDER#001 even while USER#42 / ORDER#002 already exists, because they're different items.
It's worth being precise about what this protection does and doesn't do, because people sometimes reach for it expecting more than it delivers. It stops this write, right now, on this exact key from clobbering something that's already there. It does not, on its own, give you a way to check "does this email address exist anywhere in the table regardless of what else identifies the item" if the email isn't your partition key — that's a different problem, covered in the edge cases section further down, and it needs a different design, not a bigger condition expression.
Protection 2: catching lost updates with optimistic locking
This is the classic "read-modify-write" race, and it's the reason optimistic locking exists in every database that supports it, not just DynamoDB. Two requests read the same item at nearly the same moment. Both see the same starting values. Both compute a change based on what they read. Both write back. Whichever write lands second wins completely, and it has no idea the first write ever happened — it overwrites it as if it never existed. The customer who placed the first order, or the agent who made the first edit, simply vanishes from the record with no error, no log entry, nothing.
Optimistic locking closes that gap without taking out a lock on the item, which is what makes it "optimistic" — it assumes conflicts are rare and only pays the cost of checking, not the cost of blocking every other reader while you think. Think of a lock like reserving the one photocopier in an office by standing next to it; optimistic locking is the opposite bet — everyone copies whenever they like, and the only rule is that when you go to staple your copies together, you check that nobody swapped the original page while you were gone. The pattern: every item carries a version attribute, usually a plain integer starting at 0 or 1. Every write reads the current version, includes it in a condition expression, and bumps it by one as part of the same write. If the version in the table doesn't match what you read, someone else already wrote since you read — and DynamoDB throws ConditionalCheckFailedException instead of letting your stale write clobber theirs.
✅ Why this is the one to use for shared, mutable items
Any item more than one process can update — inventory counts, account balances, shared documents, order status — should carry a version attribute and a version condition on every write. It costs one extra attribute and one extra comparison. The alternative costs you a customer's data with no error message to explain why.
A version-checked update looks like this against a sample table, in a pattern that comes straight from AWS's own SDK examples: you set a new value for the field you're changing, check the current version matches what you read, and increment the version in the same write.
UpdateExpression: "SET Genre = :newGenre, Version = :newVersion"
ConditionExpression: "Version = :expectedVersion"
ExpressionAttributeValues: {
":newGenre": "Latin Jazz",
":expectedVersion": 7,
":newVersion": 8
}If the item's stored version is still 7, the write goes through and the version becomes 8. If someone else already bumped it to 8, your condition fails, you get ConditionalCheckFailedException, and — this is the important part — nothing was overwritten. The other person's change is intact. Your job now is to decide what to do about your own change: re-read the item, reapply your intended change on top of the new version, and try again. That's the whole retry loop, and it's the only correct response to a lost-update failure. Retrying the exact same write with the exact same expected version will just fail again, forever.
Jake pushed back on this the first time Ethan explained it: "So every single update on a busy item has to fail once and try twice? That sounds slower, not safer." Ethan didn't disagree with the shape of the complaint, just the conclusion. "It's slower on the one item getting hammered, sure. But compare that to the alternative, which isn't 'faster' — it's 'wrong, silently, and you find out three weeks later when a customer calls asking where half their order went.' I'll take the occasional retry over that trade every time."
If you're on Java and using DynamoDBMapper, you don't write this by hand at all — the @DynamoDBVersionAttribute annotation adds and checks the version field automatically on every save. If you get ConditionalCheckFailedException from a mapper-managed save, it means the version on the object in memory no longer matches the version in the table; the resolution is the same re-read-and-retry pattern, or, if you genuinely don't want version checking on that particular call, saving with optimistic locking turned off for that operation. Turning it off site-wide just to silence one confusing error removes the exact protection you added the version attribute for in the first place.
Protection 3: keeping an item inside the states your business allows
This third category has nothing to do with concurrency at all — the item might not have changed in months. The condition here isn't asking "did someone beat me to this," it's asking "is this action even legal on this item right now." A condition expression can compare, test ranges, and combine checks with the same logical operators you'd use in any programming language: =, <>, <, >, <=, >=, BETWEEN, and IN for comparisons, plus AND, OR, and NOT for combining them.
Some real examples pulled straight from how these expressions actually get written:
// Only ship an order that's still marked pending ConditionExpression: "#status = :pending" // Only decrement stock if there's actually stock to decrement ConditionExpression: "stock > :zero" // Only delete a session after it's actually expired ConditionExpression: "expiresAt < :now" // Only apply a discount code once per account ConditionExpression: "NOT contains(usedCodes, :code)"
Here a jargon stop is worth taking, because "condition expression" and "filter expression" get confused constantly and they do opposite jobs. A filter expression runs on a Query or Scan and just hides items from a read result after DynamoDB has already fetched them — it costs you read capacity for the hidden items too, and it never blocks anything. A condition expression runs on a write, and if it evaluates false, the write never happens at all. One trims what you see; the other decides whether anything changes.
⚠️ What removing the condition actually breaks
Deleting ConditionExpression to stop the error doesn't fix a bug — it turns the check off. On an attribute_not_exists guard, that means duplicate signups can silently replace each other's data. On a version check, it means the last write always wins with no warning to the person whose change just vanished. On a business-rule check, it means an order can ship twice, or stock can go negative, because nothing is stopping it anymore. Every one of these failure modes looks fine in testing, because tests rarely have two writers hitting the same item in the same millisecond — and then it happens in production, on the one order that mattered.
The building blocks of a condition expression
Every condition expression you'll ever write is built from the same small set of functions and operators. There's no separate syntax to learn for PutItem versus UpdateItem versus DeleteItem — the same expression language works identically across all three.
| Function/operator | What it checks |
|---|---|
attribute_exists(path) | The attribute is present on the item. |
attribute_not_exists(path) | The attribute is absent — the classic create-only guard. |
attribute_type(path, type) | The attribute is a specific DynamoDB data type. |
begins_with(path, substr) | A string attribute starts with the given text. |
contains(path, value) | A string contains a substring, or a set/list contains a value. |
size(path) | The byte length of a string/binary, or the element count of a set/list/map. |
= <> < > <= >= | Standard comparisons between an attribute and a value. |
BETWEEN a AND b | The value falls within an inclusive range. |
IN (a, b, c) | The value matches any one of a list. |
AND / OR / NOT | Combine multiple checks into one expression. |
Function names are case-sensitive — Attribute_Not_Exists is not the same token as attribute_not_exists and will fail to parse, not fail the condition. You'll also see an older parameter called Expected in some legacy code and in older SDK documentation. It predates ConditionExpression and does a smaller version of the same job; AWS's own reference material calls it a legacy parameter now and points you to ConditionExpression instead. If you inherit code using Expected, it isn't broken, but there's no reason to write new code with it.
- Identify which of the three protections you actually need — overwrite prevention, lost-update prevention, or a business rule.
- Pick the smallest expression that enforces it. Don't stack five checks when one
attribute_not_existsdoes the job. - Alias every attribute name that could collide with a DynamoDB reserved word (
status,name,date, and dozens of others) usingExpressionAttributeNames, and every literal value usingExpressionAttributeValues. A surprising share of "my condition never works" reports turn out to be an unaliased reserved word producing aValidationExceptionbefore the condition is even evaluated. - Decide, before you ship it, what your code does on a caught ConditionalCheckFailedException — retry, surface an error to the user, or silently skip. Deciding this after the first production failure means someone is guessing under pressure.
Debugging a failure: what actually caused it
By default, ConditionalCheckFailedException tells you nothing about the item that beat your write — just that something did. That's the single biggest source of confusion around this error, and it's a solved problem you just have to know to switch on.
AWS's own knowledge center walks through the fix directly: turn on the ReturnValuesOnConditionCheckFailure parameter on your UpdateItem (or PutItem/DeleteItem) call. Set it to ALL_OLD, and if the condition fails, DynamoDB attaches the item's current attributes — the ones already sitting in the table — onto the exception itself. Set it to NONE, the default behavior if you omit the parameter, and you get the bare error with no item. This parameter doesn't cost you an extra read; the item comes back on the failure response you were already going to receive.
try:
table.update_item(
Key={"orderId": "order-123"},
UpdateExpression="SET #status = :newStatus",
ConditionExpression="#status = :expectedStatus",
ExpressionAttributeNames={"#status": "status"},
ExpressionAttributeValues={
":newStatus": "shipped",
":expectedStatus": "pending"
},
ReturnValuesOnConditionCheckFailure="ALL_OLD"
)
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
losing_item = e.response.get("Item")
# losing_item now holds the item's current attributes,
# including whatever "status" actually is right now.What that gets you in practice: instead of a log line that just says "update failed," you can log the actual current status, the actual current version number, or whichever attribute your condition was checking. That turns "something's wrong with orders" into "order-123 was already shipped by another process eleven seconds earlier" — a debuggable fact instead of a shrug.
One thing worth deciding deliberately rather than by accident: turning this parameter on means the full old item — every attribute, not just the one your condition checked — lands in your exception object, and from there it often lands in whatever error-tracking or logging service your application already ships errors to. If that item carries anything sensitive — a full shipping address, a partial payment token, a support ticket's contents — that's now sitting in a third-party log pipeline every time a condition fails. The fix isn't to skip ReturnValuesOnConditionCheckFailure; it's to log only the specific attribute your condition actually cares about, and let the rest of the item stay out of your logs.
Inside a transaction, the same failure wears a different name
If you're calling PutItem, UpdateItem, or DeleteItem directly, a failed condition throws exactly ConditionalCheckFailedException. But TransactWriteItems — DynamoDB's all-or-nothing operation that groups up to 25 put, update, delete, and standalone condition-check actions across one or more tables into a single atomic unit — behaves differently, and this trips people up hard the first time they hit it.
Inside a transaction, a failed condition doesn't throw ConditionalCheckFailedException at all. It throws TransactionCanceledException, and DynamoDB's own error reference lists a whole family of reasons a transaction can be canceled, not just condition failures: a condition in one of the condition expressions isn't met; a table in the request is in a different account or region; more than one action in the same transaction targets the same item; there's insufficient provisioned capacity to complete it; an item grows past 400 KB or a local secondary index gets too large as a result of the transaction; there's a user error like an invalid data format; or there's a conflicting concurrent TransactWriteItems request already running against one of the same items.
To find out which specific action inside the transaction actually failed and why, you read the CancellationReasons list on the exception, ordered to match the order of actions you submitted.
| Cancellation reason code | Message |
|---|---|
None | null — this action was fine. |
ConditionalCheckFailed | "The conditional request failed." |
ItemCollectionSizeLimitExceeded | "Collection size exceeded." |
TransactionConflict | "Transaction is ongoing for the item." |
ProvisionedThroughputExceeded | The configured provisioned throughput for the table was exceeded. |
AWS's own troubleshooting guide gives a real example of what that looks like on the wire: a message reading "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None, None, None]" — telling you plainly that the first of four actions was the one that failed its condition, and the other three never even got evaluated because the whole transaction rolled back together.
🕐 The one-line rule for telling these apart
- Plain
PutItem/UpdateItem/DeleteItemwith a failed condition →ConditionalCheckFailedException, one item, one reason. - Any of those same actions failing inside a
TransactWriteItemscall →TransactionCanceledException, and you have to readCancellationReasonsto find out which action, and whether a condition was even the reason. - If a single, non-transactional write is rejected because it conflicts with an item already locked by an ongoing transaction, that's a third, separate exception —
TransactionConflictException— not either of the above.
AWS's own troubleshooting guide for TransactWriteItems adds a practical detail worth knowing before you ship anything transactional: the whole call is genuinely all-or-nothing across every action you submit, up to 25 of them, possibly spread across different tables. There's no partial success to clean up after — either every action commits or none of them do. And because a network blip can mean you're not sure whether your transaction actually landed, you can attach an idempotency client token to the request so a retried call with the same token doesn't apply the same writes twice.
Where you can't use a condition at all: BatchWriteItem
This one catches people migrating from a bulk-load script to a live application. BatchWriteItem exists specifically for high-volume put and delete operations across up to 25 items and one or more tables — the kind of thing you'd use loading data from Amazon EMR or another database. Its own documentation is explicit about the tradeoff it makes for that speed: you cannot specify conditions on individual put and delete requests inside a batch. Not "conditions are optional here" — conditions aren't a parameter the API accepts at all for the items inside the batch.
There's a second gotcha buried in the same documentation that matters even more for correctness: BatchWriteItem cannot update items in the partial sense you might expect. If you run a batch put against a key that already exists, the existing item's attributes get replaced wholesale by whatever you sent — it will appear like it was updated, but any attribute you didn't include in your new item is simply gone, not preserved. If your workflow needs "change this one field, leave the rest alone," that's what UpdateItem is for, not a batch put.
If your application genuinely needs conditional writes across multiple items as one unit, TransactWriteItems is the operation built for that, with the tradeoffs from the section above — smaller batch limits and different exception handling. Reach for BatchWriteItem only when you genuinely don't need per-item conditions and you're optimizing for throughput over guarantees, like a one-time data migration.
Is ConditionalCheckFailedException retryable?
Technically and mechanically, yes — nothing stops your code from immediately calling the same operation again. Whether you should depends entirely on which of the three protections you tripped, and this is where a lot of blanket "just add a retry loop" advice goes wrong.
Contrast this with throttling errors like ProvisionedThroughputExceededException, which the AWS SDKs automatically retry on your behalf with exponential backoff, because the request itself was fine and the table was just temporarily busy. ConditionalCheckFailedException is different in kind: the SDK doesn't automatically retry it, because retrying the exact same request with the exact same condition and the exact same values will fail again, every time, for as long as the underlying state stays the same. There's nothing transient about it from the SDK's point of view — it's not a "try again later" error, it's a "you were wrong about the current state" error.
| Which failure | Retry as-is? | Correct response |
|---|---|---|
Overwrite prevention (attribute_not_exists) | No | Tell the caller the resource already exists (a 409, in HTTP terms). |
| Optimistic lock (version mismatch) | Yes, after a re-read | Re-read the item, reapply your change on the new version, write again. |
| Business rule (wrong status, empty stock) | No | Surface the real current state; retrying won't make the rule pass. |
So the honest answer is: don't build a generic "retry on ConditionalCheckFailedException" wrapper and apply it everywhere. Build a retry loop specifically for the optimistic-locking case, where a fresh read genuinely can change the outcome, and treat the other two as terminal failures that need a decision, not a loop.
Where the popular advice gets it wrong
Two pieces of advice show up constantly around this error, and both deserve pushback.
"Just remove the ConditionExpression." Covered above at length, but it bears repeating because it's the single most-repeated non-fix for this error: the condition isn't the bug, it's the feature you added on purpose. If it's now failing more than you expect, the question isn't "how do I stop it from checking," it's "why is the state different from what I assumed" — which usually means more traffic is hitting the same item than your original design accounted for.
"Retry every ConditionalCheckFailedException with exponential backoff, just like a throttling error." This treats a state-mismatch error like a capacity error, and the two aren't the same shape of problem. Blind retries on a business-rule failure or an overwrite-prevention failure just burn write capacity re-attempting a write that will never succeed, and if there's no re-read in between, blind retries on an optimistic-locking failure do the exact same thing. The loop needs to actually change something between attempts — specifically, a fresh read of the item's current state — or it isn't a retry, it's the same failed request running on repeat.
🙋♂️ Jake's Reality Check
"So what do I actually tell my developer to do — catch the error, or fix the code so it never happens?"
Ethan's answer, no hedging: "Catch it. I'd be more worried if it never showed up. A table with real concurrent writers that never throws this is either getting no traffic or missing the checks it needs. This one means two customers happened to act on the same item close together — that's a normal Tuesday, not an incident. Write code with a plan for when it happens, and stop treating the error itself as the failure."
Edge cases and the things that come up right after the fix works
Composite primary keys and attribute_not_exists
Already covered above, but worth restating on its own because it's such a common surprise: on a table with a partition key and a sort key, an attribute_not_exists check on the partition key alone does not mean "no item with this partition key exists anywhere in the table." It means no item with this exact combination of partition key and sort key exists. That's a feature for most designs — it lets one customer own many order items — but if you actually needed to guarantee partition-key uniqueness across every sort key, a single-item condition expression can't do that on its own; you'd need to model it differently, for example with a separate lookup item that carries just the value you need to be unique.
PartiQL for DynamoDB
If your team writes DynamoDB access using PartiQL's SQL-like syntax instead of the native API calls, conditional writes still apply the same way — an UPDATE or INSERT statement with a WHERE clause that doesn't match still throws ConditionalCheckFailedException underneath, because PartiQL statements compile down to the same underlying operations. The protection doesn't disappear because the syntax changed; it's the same engine underneath.
Global secondary indexes don't help you here
A condition expression evaluates against the base table item, using whatever attributes exist on it at write time — it has no visibility into a global secondary index's eventually-consistent projection. If you're trying to enforce uniqueness on an attribute that isn't your partition key, a GSI can help you find duplicates after the fact, but it can't stop the write from happening the way a condition expression on the base table's key can.
Designing the version attribute in from day one, not retrofitting it
The teams who never get burned by lost updates are almost always the ones who added a version attribute to a shared item's schema before the first write ever happened, not after the first incident. Retrofitting one onto a table that's already live means every existing item needs a default version value before you can start enforcing the condition, and every piece of code that touches that item needs updating in the same release — miss one write path and you've got a condition that half your application respects and half silently ignores. If you're designing a new table today and you already know more than one process will ever touch a given item, add the version attribute at design time. It's a much smaller conversation now than after the first customer complaint.
Wrapping the boilerplate instead of repeating it
Every example in this post aliases attribute names and values by hand, and doing that correctly, every single time, across a whole codebase, is exactly the kind of repetitive work that invites the one typo that breaks a condition silently. Plenty of teams write a thin internal wrapper around the SDK's put, update, and delete calls that handles the aliasing and the version increment automatically, so the application code just says "update this field if the version still matches" without spelling out the expression by hand each time. That's an engineering choice about your own codebase, not a DynamoDB feature — the underlying condition expression and the exception it throws are exactly the same either way.
Monitoring how often this happens in production
You don't have to wait for a support ticket to find out your condition checks are failing constantly. DynamoDB emits a per-table count of failed conditional writes, so you can graph it in CloudWatch and set an alarm if it spikes. Jake's shop has a similar rhythm to watch for on the till side of the business — the card reader at the second register always throws a timeout on Sunday afternoons when the mall's Wi-Fi gets saturated, and nobody thought to graph it until three Sundays of "the machine's just being weird again" turned into an actual pattern worth fixing. A sudden jump in failed conditional writes usually means the same thing: either a real concurrency problem got worse, or a deploy introduced a condition that no longer matches reality, such as a renamed status value or a version field that isn't being incremented somewhere in a code path someone forgot about.
Testing this locally before it surprises you in production
DynamoDB Local, the downloadable version AWS provides for offline development, evaluates condition expressions the same way the hosted service does — it's the same expression engine, running on your own machine instead of in an AWS Region. That means you can and should write a test that deliberately trips your own conditions before you ship them: create an item, then attempt a second create with the same key, and confirm you actually get ConditionalCheckFailedException back, rather than assuming the condition works because the code compiled without error. A condition expression with a typo in an attribute name, or an update expression that forgets to bump the version field, will often run without complaint in a quick manual test and then behave completely differently the first time two real writers collide on the same item — which is exactly the kind of gap a short concurrent test catches for free, long before a customer does.
The privacy angle worth a second thought
It's easy to treat a condition expression purely as a concurrency tool and forget it also decides what a would-be attacker can learn about an item they shouldn't be able to touch. A poorly worded condition can leak information through its failure alone — if your API returns a different error message for "item doesn't exist" versus "item exists but the condition failed," that distinction can tell an outside caller whether a given account, order, or discount code is real, even if the underlying write is correctly blocked either way. Where that matters, keep the outward-facing error message identical for both cases, and reserve the detailed reason — the one you get from ReturnValuesOnConditionCheckFailure — for your own internal logs, not the response you send back to the caller.
Frequently asked questions
What does ConditionalCheckFailedException actually mean?
It means a ConditionExpression you attached to a PutItem, UpdateItem, or DeleteItem call evaluated to false at the moment DynamoDB tried to run it, so the write was rejected before it touched the item. Nothing in the table changed as a result of your call.
Is ConditionalCheckFailedException a bug in my code?
Not by itself. It's your application discovering that the item's real, current state doesn't match what you assumed it was. Whether that's expected, like two customers hitting checkout at once, or a real problem, like checking the wrong attribute or forgetting to increment a version field, is something only you can tell from the specific case.
Should I just remove the ConditionExpression to make the error go away?
No. The condition is what's preventing an overwrite, a lost update, or an invalid state change. Removing it stops the error message and also stops the protection, which usually surfaces later as silently missing data instead of a caught exception.
How do I see which value caused the condition to fail?
Set ReturnValuesOnConditionCheckFailure to ALL_OLD on the write call. If the condition fails, DynamoDB attaches the item's current attributes to the exception, so you can log or inspect exactly what the table actually contains right now.
Does ReturnValuesOnConditionCheckFailure cost extra?
No additional read is performed to fetch the item; it's returned as part of the failure response you were already receiving. The valid values are ALL_OLD, which returns the item, and NONE, the default, which returns nothing.
What's the difference between ConditionalCheckFailedException and TransactionCanceledException?
ConditionalCheckFailedException is thrown by a single, direct PutItem, UpdateItem, or DeleteItem call. When the same kind of condition failure happens on an action inside a TransactWriteItems call, the whole transaction is rejected with TransactionCanceledException instead, and you have to check the CancellationReasons list to find out which action failed and why, since a transaction can also be canceled for reasons that have nothing to do with a condition.
Can I use a condition expression with BatchWriteItem?
No. BatchWriteItem's own documentation states plainly that you cannot specify conditions on the individual put and delete requests inside a batch. If you need conditional writes across multiple items, TransactWriteItems is the operation designed for that.
Does DynamoDB automatically retry a failed conditional write?
No. The AWS SDKs automatically retry certain transient errors, like provisioned-throughput exhaustion, because retrying an identical request can succeed once the table is less busy. ConditionalCheckFailedException isn't treated that way, because retrying the exact same request with the exact same condition will keep failing until something about the underlying state actually changes.
How do I implement optimistic locking with DynamoDBMapper?
On the Java SDK's object mapper, add the @DynamoDBVersionAttribute annotation to a numeric field on your mapped class. The mapper checks and increments that field automatically on every save; a mismatch between the version on your in-memory object and the version currently stored in the table throws ConditionalCheckFailedException, and the correct response is to re-load the object and reapply your change.
What happens if two requests hit the same item at the exact same millisecond?
One of them wins the underlying write and the other's condition fails, because DynamoDB evaluates the condition and performs the write as a single atomic step on that item — there's no window where both requests can pass the check before either one writes. Which specific request wins isn't something your application controls or should rely on; what matters is that the loser gets a clear, catchable failure instead of silently overwriting the winner.
Does attribute_not_exists work on a table with a sort key?
Yes, but remember what it's actually testing: whether an item with that exact partition key and sort key combination already exists, not whether the partition key appears anywhere else in the table. That distinction matters a lot on tables where one partition key intentionally owns many sort-keyed items.
Can I check more than one attribute in a single condition expression?
Yes. Condition expressions support AND, OR, and NOT, so you can combine multiple checks in one expression — for example, requiring both that an order's status is still pending and that its version matches what you read, in a single atomic check.
Why did I get ConditionalCheckFailedException when I didn't write a ConditionExpression myself?
Check whether you're using an object mapper or a library with optimistic locking enabled by default — DynamoDBMapper's @DynamoDBVersionAttribute is a common source of this, since it adds and checks a version condition on every save without you writing the expression by hand. It's also worth checking any internal wrapper library your team maintains, since these often add a default existence check on creates.
Is a condition expression the same as a DynamoDB transaction?
No. A condition expression is a guard on a single item within one write call. A transaction (TransactWriteItems) coordinates up to 25 actions across one or more items and tables so that they all succeed or all fail together. A transaction can include condition expressions on some or all of its actions, but the two are different mechanisms serving different scopes.
How do I monitor how often condition checks are failing in production?
DynamoDB publishes a per-table metric counting failed conditional writes, viewable in CloudWatch. A steady, expected baseline reflects normal concurrent traffic; a sudden spike after a deploy is usually a sign that a condition no longer matches the data it's checking against, or that concurrent traffic increased more than the design anticipated.
What's the safest way to retry after a ConditionalCheckFailedException?
Only for the optimistic-locking case: re-read the item to get its current state and version, reapply your intended change on top of that fresh data, and write again with the new version as your expected value. For overwrite-prevention and business-rule failures, retrying the identical write is almost never correct — the right move is usually to surface the real, current state to whoever triggered the action instead of looping.
Revision note. Written September 2026, covering the current DynamoDB condition-expression syntax, ReturnValuesOnConditionCheckFailure, TransactWriteItems cancellation behavior, and DynamoDBMapper's version-attribute pattern. It will need a look if DynamoDB changes how transaction cancellation reasons are surfaced or adds conditions to BatchWriteItem. If you landed here staring at this error in a production incident: it means your data is safe, not broken — take the win, read the item that beat you, and fix the retry loop once things are calm.