DynamoDB’s conditional writes are the key to making multiple operations atomic, meaning they either all succeed or all fail together, preventing data inconsistencies.
Let’s see this in action. Imagine we have a ShoppingCart table with items, and we want to decrement the stock_count of a Product when it’s added to a cart, but only if there’s enough stock.
Here’s a simplified Product table structure:
{
"TableName": "Products",
"KeySchema": [
{"AttributeName": "product_id", "KeyType": "HASH"}
],
"AttributeDefinitions": [
{"AttributeName": "product_id", "AttributeType": "S"}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5
},
"ItemAttributes": {
"product_id": "PROD123",
"stock_count": 10
}
}
Now, let’s say a user wants to add "PROD123" to their cart. We need to update the Products table. A naive UpdateItem call would look like this:
import boto3
dynamodb = boto3.resource('dynamodb')
products_table = dynamodb.Table('Products')
response = products_table.update_item(
Key={'product_id': 'PROD123'},
UpdateExpression='SET stock_count = stock_count - :val',
ExpressionAttributeValues={':val': 1}
)
This works most of the time. But what if two users try to add the last item simultaneously? Both update_item calls might read stock_count as 1, then both decrement it, resulting in stock_count becoming -1. This is a race condition, and it breaks our inventory.
This is where conditional writes shine. We can add a condition to our update_item call that checks the current stock_count before applying the update.
Here’s the improved version:
import boto3
dynamodb = boto3.resource('dynamodb')
products_table = dynamodb.Table('Products')
try:
response = products_table.update_item(
Key={'product_id': 'PROD123'},
UpdateExpression='SET stock_count = stock_count - :val',
ConditionExpression='stock_count >= :min_stock',
ExpressionAttributeValues={
':val': 1,
':min_stock': 1
}
)
print("Item added to cart successfully. Stock updated.")
except products_table.meta.client.exceptions.ConditionalCheckFailedException:
print("Failed to add item to cart. Insufficient stock or item was updated by another process.")
Notice the ConditionExpression='stock_count >= :min_stock' and the ExpressionAttributeValues. This tells DynamoDB: "Only perform this update if the current stock_count is greater than or equal to 1." If this condition is not met, DynamoDB will return a ConditionalCheckFailedException and the update will not be applied. The stock_count will remain unchanged.
This pattern is crucial for maintaining data integrity in scenarios like:
- Inventory Management: As shown, ensuring you don’t oversell items.
- Financial Transactions: Preventing duplicate charges or ensuring a balance is sufficient before a withdrawal.
- State Transitions: Only allowing a state change if the item is in the expected current state (e.g., an order can only be "shipped" if it’s currently "processing").
- Leader Election: A process can only claim leadership if the "leader" attribute is null or set to a stale value.
The ConditionExpression can be quite powerful. You can check for the existence of an attribute (attribute_exists(some_attribute)), its absence (attribute_not_exists(some_attribute)), or compare attribute values using standard operators (<, >, =, <=, >=, <>). You can also combine multiple conditions using AND and OR.
For example, to ensure an item is only updated if its version attribute matches a specific value and stock_count is sufficient:
response = products_table.update_item(
Key={'product_id': 'PROD123'},
UpdateExpression='SET stock_count = stock_count - :val, version = version + :inc',
ConditionExpression='stock_count >= :min_stock AND version = :expected_version',
ExpressionAttributeValues={
':val': 1,
':min_stock': 1,
':expected_version': 5,
':inc': 1
}
)
This ensures that even if stock_count is sufficient, the update will fail if another process has already updated the version number, preventing lost updates.
The most surprising thing about conditional writes is how often they are the only correct way to implement certain logic in a distributed system. Without them, you’re essentially rolling the dice on data consistency. Every UpdateItem, PutItem, or DeleteItem operation can have a ConditionExpression attached. This makes them a fundamental building block for reliable DynamoDB applications.
The ConditionExpression is evaluated atomically with the write operation itself. If the condition evaluates to false, the write is aborted, and DynamoDB returns ConditionalCheckFailedException. This means you don’t need to perform a separate GetItem call to check the condition beforehand, which would introduce its own race conditions and increase latency. The entire check-and-write happens as a single, atomic operation on the DynamoDB service side.
What many people miss is that ConditionExpression applies to the entire item. If you are updating multiple attributes in a single UpdateItem call, and your ConditionExpression fails, none of the attributes in that UpdateItem call will be modified. This is by design, as it guarantees atomicity for the set of changes you intended to make, provided the condition is met.
When a ConditionalCheckFailedException occurs, you don’t incur write capacity units for the operation that was attempted but failed due to the condition. However, you will consume read capacity units if your condition involved reading attribute values from other items (though this is less common for simple conditions).
The next hurdle you’ll likely face is understanding how to implement these conditional writes across multiple items or tables to achieve transactional guarantees, which is where DynamoDB Transactions come into play.