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

DynamoDB: Tables and Keys

Create a DynamoDB table with a partition key and sort key, put two items under the same partition, and query them with begins_with — all against the real DynamoDB Local emulator, no AWS account needed.

By SkillByExample · Updated

aws dynamodb nosql

DynamoDB tables don’t have a fixed schema, but every table has a primary key, and that key shapes everything about how data is stored and retrieved. This lesson uses a composite primary key — a partition key plus a sort key — and runs against DynamoDB Local, AWS’s own Docker-based emulator, so there’s no AWS account or billing involved anywhere in this lesson.

Creating a Table With a Partition Key and Sort Key

CustomerId is the partition key (which customer), OrderId is the sort key (which order, within that customer) — together they’re the only way to address one specific item. The aws() function below runs the real AWS CLI from its own Docker image, since no local install is needed.

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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 \
  --endpoint-url http://aws11-ddb:8000 \
  --query 'TableDescription.{TableName:TableName,TableStatus:TableStatus,KeySchema:KeySchema}'
$ docker network create aws11-lab
275064355a5179ade5ee35db5b35bb0645c5be72b3ca398df7f1cbeb1354c9d2
$ docker run -d --name aws11-ddb --network aws11-lab amazon/dynamodb-local:3.3.2
b0abda3b6983a5ff4b8e88721f1e218eef5a9b6ccfa7a4342bfe73fb51e336fc
$ until docker run --rm --network aws11-lab curlimages/curl:8.11.1 -s -o /dev/null http://aws11-ddb:8000; do sleep 0.3; done
$ bash create_table.sh
{
    "TableName": "Orders",
    "TableStatus": "ACTIVE",
    "KeySchema": [
        {
            "AttributeName": "CustomerId",
            "KeyType": "HASH"
        },
        {
            "AttributeName": "OrderId",
            "KeyType": "RANGE"
        }
    ]
}

The table is ACTIVE immediately — DynamoDB Local has no provisioning delay the way a real table briefly does. PAY_PER_REQUEST billing means there’s no fixed read/write capacity to size ahead of time; every request is metered individually, which is what the ConsumedCapacity numbers below actually measure.

Putting Two Items Under One Partition Key

Both orders share CustomerId: alice, but have different OrderId values — exactly what a sort key is for.

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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":"30"}}' \
  --return-consumed-capacity TOTAL \
  --endpoint-url http://aws11-ddb:8000
aws dynamodb put-item --table-name Orders \
  --item '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-002"},"total":{"N":"55"}}' \
  --return-consumed-capacity TOTAL \
  --endpoint-url http://aws11-ddb:8000
$ bash put_orders.sh
{
    "ConsumedCapacity": {
        "TableName": "Orders",
        "CapacityUnits": 1.0
    }
}
{
    "ConsumedCapacity": {
        "TableName": "Orders",
        "CapacityUnits": 1.0
    }
}

put-item with no --return-consumed-capacity wouldn’t show this at all; each write costs 1.0 write capacity unit here because both items are well under a single write unit’s per-item size cap, as the FAQ below details.

Querying by Partition Key, Filtered by Sort-Key Prefix

Query needs the exact partition key, plus (optionally) a condition on the sort key — begins_with is one of several sort-key conditions it supports.

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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 query --table-name Orders \
  --key-condition-expression "CustomerId = :c AND begins_with(OrderId, :p)" \
  --expression-attribute-values '{":c":{"S":"alice"},":p":{"S":"o-"}}' \
  --return-consumed-capacity TOTAL \
  --endpoint-url http://aws11-ddb:8000 \
  --query '{Count:Count,ScannedCount:ScannedCount,ConsumedCapacity:ConsumedCapacity,OrderIds:Items[].OrderId.S}'
$ bash query_orders.sh
{
    "Count": 2,
    "ScannedCount": 2,
    "ConsumedCapacity": {
        "TableName": "Orders",
        "CapacityUnits": 0.5
    },
    "OrderIds": [
        "o-001",
        "o-002"
    ]
}

Both orders come back — begins_with(OrderId, "o-") matches both o-001 and o-002 — and the query costs only 0.5 read capacity units for the pair: Query defaults to an eventually-consistent read, which is priced at half of a strongly-consistent read’s unit cost, and both tiny items together fit well under one unit either way. Count and ScannedCount match here because nothing was filtered out after the key condition narrowed the scan to exactly these two items; the next lesson shows a case where they diverge.

A Point Read by the Full Composite Key

GetItem needs both halves of the key, not a range — for one small item like this, it costs the same as a Query that happens to return one item (both are a single eventually-consistent read unit); the real advantage is precision, not price: GetItem only works when you already know exactly which one item you want.

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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 get-item --table-name Orders \
  --key '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-001"}}' \
  --endpoint-url http://aws11-ddb:8000
$ bash get_order.sh
{
    "Item": {
        "OrderId": {
            "S": "o-001"
        },
        "total": {
            "N": "30"
        },
        "CustomerId": {
            "S": "alice"
        }
    }
}

GetItem returns one item directly, with no Count/ScannedCount — there’s nothing to scan, it goes straight to the one partition holding that exact key.

Practice

1. Put a third order for a different customer, then confirm alice's query from above still returns only her two orders.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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":"bob"},"OrderId":{"S":"o-010"},"total":{"N":"18"}}' \
  --endpoint-url http://aws11-ddb:8000
aws dynamodb query --table-name Orders \
  --key-condition-expression "CustomerId = :c" \
  --expression-attribute-values '{":c":{"S":"alice"}}' \
  --endpoint-url http://aws11-ddb:8000 \
  --query '{Count:Count,OrderIds:Items[].OrderId.S}'
$ bash practice1.sh
{
    "Count": 2,
    "OrderIds": [
        "o-001",
        "o-002"
    ]
}

Still just alice’s two orders — a Query without a sort-key condition still requires the exact partition key, so bob’s order under a different partition key never enters the result.

2. Try GetItem with only the partition key on this table and read the real error.
#!/usr/bin/env bash
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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 get-item --table-name Orders \
  --key '{"CustomerId":{"S":"alice"}}' \
  --endpoint-url http://aws11-ddb:8000
$ bash practice2.sh

aws: [ERROR]: An error occurred (ValidationException) when calling the GetItem operation: The number of conditions on the keys is invalid

A ValidationException, not a “not found” — half of a composite key isn’t a valid key at all, so DynamoDB rejects the request before it ever looks for an item.

3. Query alice's orders with a sort-key prefix that matches nothing, and check Count/ScannedCount/ConsumedCapacity.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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 query --table-name Orders \
  --key-condition-expression "CustomerId = :c AND begins_with(OrderId, :p)" \
  --expression-attribute-values '{":c":{"S":"alice"},":p":{"S":"zzz"}}' \
  --return-consumed-capacity TOTAL \
  --endpoint-url http://aws11-ddb:8000
$ bash practice3.sh
{
    "Items": [],
    "Count": 0,
    "ScannedCount": 0,
    "ConsumedCapacity": {
        "TableName": "Orders",
        "CapacityUnits": 0.0
    }
}

Items is empty and Count/ScannedCount are both 0 — DynamoDB knows from the partition key alone that there’s nothing under this prefix, so it scans zero items, not “two items that didn’t match.”

4. Overwrite o-001's total with a second put-item for the same key, and confirm it replaced rather than added a new item.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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"}}' \
  --endpoint-url http://aws11-ddb:8000
aws dynamodb query --table-name Orders \
  --key-condition-expression "CustomerId = :c" \
  --expression-attribute-values '{":c":{"S":"alice"}}' \
  --endpoint-url http://aws11-ddb:8000 \
  --query '{Count:Count,Orders:Items[].{OrderId:OrderId.S,total:total.N}}'
$ bash practice4.sh
{
    "Count": 2,
    "Orders": [
        {
            "OrderId": "o-001",
            "total": "999"
        },
        {
            "OrderId": "o-002",
            "total": "55"
        }
    ]
}

Still only 2 items under alice’s partition key — put-item with the same full key (CustomerId + OrderId) replaces the existing item’s attributes wholesale, it never creates a duplicate.

5. Delete one of alice's orders and confirm the query result shrinks by one.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws11-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 delete-item --table-name Orders \
  --key '{"CustomerId":{"S":"alice"},"OrderId":{"S":"o-002"}}' \
  --endpoint-url http://aws11-ddb:8000
aws dynamodb query --table-name Orders \
  --key-condition-expression "CustomerId = :c" \
  --expression-attribute-values '{":c":{"S":"alice"}}' \
  --endpoint-url http://aws11-ddb:8000 \
  --query '{Count:Count,OrderIds:Items[].OrderId.S}'
$ bash practice5.sh
{
    "Count": 1,
    "OrderIds": [
        "o-001"
    ]
}

Count drops to 1 and only o-001 remains — DeleteItem needs the same full composite key as GetItem, for the same reason: neither half alone addresses one item.

Cleanup

$ docker rm -f aws11-ddb
aws11-ddb
$ docker network rm aws11-lab
aws11-lab

Next: scanning with a filter, the total reserved-word error, and adding a global secondary index to this same table.

Frequently Asked Questions

What's the difference between a partition key and a sort key?
The partition key decides which physical partition an item lives on — every item sharing a partition key value lands together. The sort key then orders items within that same partition key, so a table with both (a 'composite' primary key) can hold many items per partition key, retrievable as a range via Query. A table with only a partition key can hold exactly one item per key value, retrievable only with GetItem.
Can I run DynamoDB locally without an AWS account?
Yes, with AWS's own DynamoDB Local, distributed as a Docker image (amazon/dynamodb-local). It implements the real DynamoDB API faithfully for everyday operations — tables, items, queries, conditional writes, Streams — with no AWS account, no billing, and no network access required. It diverges in a few places later lessons in this track call out explicitly: no real throttling, no TTL-based deletion, and its ARNs say `ddblocal` instead of a real region.
Why does GetItem fail when I only supply the partition key on a table with a sort key?
Once a table has a sort key, the primary key is the pair (partition key, sort key) together — neither alone is a valid key. GetItem needs the exact key of exactly one item, so supplying only half of a composite key raises a ValidationException ("The number of conditions on the keys is invalid") rather than returning the first match or every item under that partition key.
What does ConsumedCapacity actually measure?
The number of read or write capacity units a request used. A write unit covers up to 1 KB; a strongly-consistent read unit covers up to 4 KB, but Query's default is an eventually-consistent read, which costs half of a strongly-consistent read's unit price — that halving, not a general 'reads cost half of writes' rule, is why this lesson's two-item Query costs 0.5 read capacity units. It's a cost/throughput signal, not a correctness one, and DynamoDB Local's own numbers don't always match real AWS's edge cases exactly — Practice 3 finds a query that scanned zero items reporting CapacityUnits 0.0 here, where a real table may still apply its own per-request minimum.