AWS · lesson 13 of 19
Beginner
4Intermediate
13- 5 VPC and Networking Basics
- 6 RDS Basics
- 7 AWS Lambda Fundamentals
- 8 CloudFormation Basics
- 9 CloudWatch Monitoring Basics
- 11 DynamoDB: Tables and Keys
- 12 DynamoDB: Scan, Reserved Words, and Global Secondary Indexes
- 13 DynamoDB: Conditional Writes, Atomic Counters, and Streams
- 14 SQS: Visibility Timeout and Dead-Letter Queues
- 15 SQS: FIFO Queues, Message Groups, and Deduplication
- 16 SNS: Fan-Out to SQS and Filter Policies
- 17 ELB and Auto Scaling: Wiring an ASG Behind a Load Balancer
- 18 ECS: Task Definitions and Services
Also part of AWS
8- + Linting AWS IAM Policies Before Deploy with Parliament from Cloud Security
- + Auditing the AWS IAM Credential Report with Python from Cloud Security
- + Hunting in AWS CloudTrail Logs with Python from Cloud Security
- + Dead-Letter Queues: What Happens When Retries Keep Failing from Event-Driven Architecture
- + Idempotent Consumers: Surviving a Duplicate Delivery from Event-Driven Architecture
- + Load Balancing: Round Robin vs Least Connections from System Design
- + Kubernetes Fundamentals from Kubernetes
- + Horizontal Pod Autoscaling: Real Replicas, Real CPU Numbers from Kubernetes
DynamoDB: Conditional Writes, Atomic Counters, and Streams
Block a duplicate write with a ConditionExpression, increment a counter atomically with an update expression, then read the resulting change straight off DynamoDB Streams — all against the real DynamoDB Local emulator.
By SkillByExample · Updated
Two DynamoDB features that don’t show up in a basic put/get/query tour: a ConditionExpression that blocks a write outright based on the item’s current state, and Streams, a time-ordered record of item-level changes (not quite every write — a no-op write that doesn’t actually change anything produces no record, and the stream itself only retains 24 hours of history), readable independently of the table itself. Both run for real against DynamoDB Local.
Blocking a Duplicate Write
attribute_not_exists(OrderId) only allows the write through if no item with this exact key
exists yet — a server-side, atomic “insert only if new.”
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb create-table --table-name Orders \
--attribute-definitions AttributeName=CustomerId,AttributeType=S AttributeName=OrderId,AttributeType=S \
--key-schema AttributeName=CustomerId,KeyType=HASH AttributeName=OrderId,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES \
--endpoint-url http://aws13-ddb:8000 \
--query 'TableDescription.{Status:TableStatus,StreamArn:LatestStreamArn}'
$ docker network create aws13-lab
3d7db25e5a9f5152a004f96cb8531218090f9fbb46adcba64252c0cd27bf2c96
$ docker run -d --name aws13-ddb --network aws13-lab amazon/dynamodb-local:3.3.2
1ea8aaee9e91fec1cbd3e72bce06d69ec9b3cc790a94ed217cab9ec0df94e366
$ until docker run --rm --network aws13-lab curlimages/curl:8.11.1 -s -o /dev/null http://aws13-ddb:8000; do sleep 0.3; done
$ bash setup.sh
{
"Status": "ACTIVE",
"StreamArn": "arn:aws:dynamodb:ddblocal:000000000000:table/Orders/stream/2026-11-09T09:01:47.717"
}
LatestStreamArn says ddblocal where a real table’s ARN would carry a region like us-east-1 —
the one place this lesson’s ARNs visibly differ from production, called out so the rest of this
lesson’s real stream mechanics aren’t mistaken for an emulator quirk.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb put-item --table-name Orders \
--item '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-001"},"total":{"N":"15"}}' \
--condition-expression "attribute_not_exists(OrderId)" \
--endpoint-url http://aws13-ddb:8000
echo "first put: ok"
$ bash first_put.sh
first put: ok
The condition passes — no item with this key existed yet, so attribute_not_exists(OrderId) is
true and the write goes through. The next attempt, with the exact same key, is where the
condition actually earns its keep.
#!/usr/bin/env bash
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb put-item --table-name Orders \
--item '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-001"},"total":{"N":"999"}}' \
--condition-expression "attribute_not_exists(OrderId)" \
--endpoint-url http://aws13-ddb:8000
$ bash duplicate_put.sh
aws: [ERROR]: An error occurred (ConditionalCheckFailedException) when calling the PutItem operation: The conditional request failed
The second write never happens — o-001 already exists, so attribute_not_exists(OrderId) is
false and DynamoDB rejects the whole request with ConditionalCheckFailedException before
touching any data. total is still 15, not 999.
An Atomic Counter Update
total is reserved (see the previous lesson), so the update uses the same #t alias to add 5
to it in one atomic server-side step.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb update-item --table-name Orders \
--key '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-001"}}' \
--update-expression "SET #t = #t + :d" \
--expression-attribute-names '{"#t":"total"}' \
--expression-attribute-values '{":d":{"N":"5"}}' \
--return-values ALL_NEW \
--endpoint-url http://aws13-ddb:8000
$ bash increment.sh
{
"Attributes": {
"OrderId": {
"S": "o-001"
},
"total": {
"N": "20"
},
"CustomerId": {
"S": "alice"
}
}
}
total goes from 15 to 20 in one request — no separate read happened on the client side at all,
which is exactly what makes this safe under concurrent increments on the same item.
Reading the Change Off DynamoDB Streams
Every write above — the insert and the increment — is also sitting in the table’s stream as its own record, independent of the table’s current state.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
STREAM_ARN=$(aws dynamodbstreams list-streams --table-name Orders \
--endpoint-url http://aws13-ddb:8000 --query 'Streams[0].StreamArn' --output text)
SHARD_ID=$(aws dynamodbstreams describe-stream --stream-arn "$STREAM_ARN" \
--endpoint-url http://aws13-ddb:8000 --query 'StreamDescription.Shards[0].ShardId' --output text)
ITER=$(aws dynamodbstreams get-shard-iterator --stream-arn "$STREAM_ARN" --shard-id "$SHARD_ID" \
--shard-iterator-type TRIM_HORIZON --endpoint-url http://aws13-ddb:8000 --query 'ShardIterator' --output text)
aws dynamodbstreams get-records --shard-iterator "$ITER" --endpoint-url http://aws13-ddb:8000 \
--query 'Records[].{eventName:eventName,OldTotal:dynamodb.OldImage.total.N,NewTotal:dynamodb.NewImage.total.N}'
$ bash read_stream.sh
[
{
"eventName": "INSERT",
"OldTotal": null,
"NewTotal": "15"
},
{
"eventName": "MODIFY",
"OldTotal": "15",
"NewTotal": "20"
}
]
Two records: an INSERT with no OldTotal (there was nothing before it), then a MODIFY with
OldTotal: 15 and NewTotal: 20 — the exact before/after of the atomic increment above, visible
on the stream even though nothing re-read the table itself to get it.
Practice
1. Try the same conditional insert pattern for a brand-new key and confirm it succeeds where the duplicate failed.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb put-item --table-name Orders \
--item '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-002"},"total":{"N":"8"}}' \
--condition-expression "attribute_not_exists(OrderId)" \
--endpoint-url http://aws13-ddb:8000
echo "o-002 inserted"
$ bash practice1.sh
o-002 inserted
Succeeds silently — attribute_not_exists(OrderId) is a condition on this specific key
(alice/o-002), not a table-wide rule, so a genuinely new key always passes it.
2. Decrement total by more than its current value and confirm DynamoDB allows it to go negative (no built-in floor).
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb update-item --table-name Orders \
--key '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-002"}}' \
--update-expression "SET #t = #t - :d" \
--expression-attribute-names '{"#t":"total"}' \
--expression-attribute-values '{":d":{"N":"50"}}' \
--return-values ALL_NEW \
--endpoint-url http://aws13-ddb:8000
$ bash practice2.sh
{
"Attributes": {
"OrderId": {
"S": "o-002"
},
"total": {
"N": "-42"
},
"CustomerId": {
"S": "alice"
}
}
}
total becomes -42 — the update expression does plain arithmetic with no floor at zero; a real
“never go negative” rule needs its own ConditionExpression (e.g. #t >= :d) added to the same
request.
3. Add a ConditionExpression to an update-item call that requires the current total to be at least as large as the amount being subtracted, and trigger its failure.
#!/usr/bin/env bash
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
aws dynamodb update-item --table-name Orders \
--key '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-002"}}' \
--update-expression "SET #t = #t - :d" \
--condition-expression "#t >= :d" \
--expression-attribute-names '{"#t":"total"}' \
--expression-attribute-values '{":d":{"N":"10"}}' \
--endpoint-url http://aws13-ddb:8000
$ bash practice3.sh
aws: [ERROR]: An error occurred (ConditionalCheckFailedException) when calling the UpdateItem operation: The conditional request failed
total is -42 from the previous task, so #t >= :d (-42 >= 10) is false and the update is
rejected with the same ConditionalCheckFailedException as the duplicate insert — a
ConditionExpression works identically on UpdateItem as it does on PutItem.
4. Read the stream again after the practice tasks' writes and count how many new records appeared.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws13-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@"; }
STREAM_ARN=$(aws dynamodbstreams list-streams --table-name Orders \
--endpoint-url http://aws13-ddb:8000 --query 'Streams[0].StreamArn' --output text)
SHARD_ID=$(aws dynamodbstreams describe-stream --stream-arn "$STREAM_ARN" \
--endpoint-url http://aws13-ddb:8000 --query 'StreamDescription.Shards[0].ShardId' --output text)
ITER=$(aws dynamodbstreams get-shard-iterator --stream-arn "$STREAM_ARN" --shard-id "$SHARD_ID" \
--shard-iterator-type TRIM_HORIZON --endpoint-url http://aws13-ddb:8000 --query 'ShardIterator' --output text)
aws dynamodbstreams get-records --shard-iterator "$ITER" --endpoint-url http://aws13-ddb:8000 \
--query 'length(Records)'
$ bash practice4.sh
4
4 records total now: the original INSERT + MODIFY from the main lesson, plus task 1’s
INSERT of o-002 and task 2’s MODIFY decrementing it — task 3’s rejected update never
touched the table, so it left no stream record at all.
Cleanup
$ docker rm -f aws13-ddb
aws13-ddb
$ docker network rm aws13-lab
aws13-lab
Next: SQS visibility timeouts and dead-letter queues.