Skip to main content
AWS · lesson 13 of 19
AWS intermediate Lesson 13 of 19

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

aws dynamodb nosql

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.

Frequently Asked Questions

What does attribute_not_exists(OrderId) actually check, and against what?
It's a ConditionExpression evaluated server-side against the item DynamoDB finds at the given key, atomically with the write itself. attribute_not_exists(OrderId) is true only if no item currently has that attribute — which, since OrderId is part of the key, means true only if no item with this key exists yet. If the condition evaluates false, the entire write is rejected with ConditionalCheckFailedException and nothing changes, even under concurrent writers racing the same key.
Why use an UpdateExpression like SET total = total + :d instead of reading, adding, and writing back?
A read-modify-write done as three separate client-side steps has a race: two concurrent increments can both read the same starting value and one overwrite the other's result. SET total = total + :d (with an ExpressionAttributeNames alias, since total is reserved) happens as one atomic operation inside DynamoDB itself — every concurrent increment is applied in some serial order, and none of them is lost.
What's the difference between DynamoDB Streams' NEW_AND_OLD_IMAGES and other StreamViewType options?
StreamViewType controls what a stream record carries for a changed item: KEYS_ONLY carries just the key, NEW_IMAGE the item's full state after the change, OLD_IMAGE its state before, and NEW_AND_OLD_IMAGES both — which is the only view that can show a MODIFY record's actual before/after values side by side, as this lesson's stream read does.
Does DynamoDB Streams guarantee a consumer sees every change exactly once?
AWS documents each record as appearing in the shard exactly once, with application order within a single item's key preserved — the stream itself doesn't duplicate records. A consumer can still see the same record more than once in practice, though: one that crashes and resumes from an earlier checkpointed shard iterator re-reads records it already processed. Production consumers (Lambda triggers, the Kinesis Client Library) checkpoint their position and must tolerate that kind of reprocessing, which is also why idempotent downstream handling matters for streamed changes, not just for the SQS/SNS lessons later in this track.