Access Patterns
Design DynamoDB tables around access patterns, items, indexes, streams, TTL, transactions, and conditional writes.
Mental model
DynamoDB is not a relational database with a different syntax. It's a key-value and document store where your schema is designed around your read patterns, not your data shape. The question you ask before writing a single line of code: *"How will I read this data?"*, not *"What does this data look like?"*
Prerequisites
IAM & Access Pattern Thinking
Goal
Get DynamoDB permissions right. More importantly, establish the access-pattern-first mindset before designing any table. Every bad DynamoDB schema starts with someone who skipped this step.
Estimated time: 45 min
DynamoDB IAM model
What's happening here
DynamoDB IAM is action-based. Common actions you'll see in every lab:
dynamodb:PutItem: create or overwrite an itemdynamodb:GetItem: read a single item by exact PK (+ SK)dynamodb:UpdateItem: modify specific attributes of an existing itemdynamodb:DeleteItem: remove an itemdynamodb:Query: read multiple items matching a PK value, optionally filtered by SKdynamodb:Scan: read every item in the table (expensive, almost always wrong)dynamodb:BatchWriteItem/dynamodb:BatchGetItem: bulk operationsdynamodb:TransactWriteItems: atomic multi-item writes
These are set at the table level via the resource ARN: arn:aws:dynamodb:REGION:ACCOUNT:table/TABLE_NAME. You can also scope to indexes: arn:aws:dynamodb:REGION:ACCOUNT:table/TABLE_NAME/index/*. A subtle but critical point: having dynamodb:GetItem does NOT give you dynamodb:Query. They are separate permissions. A Lambda that can read individual items by ID cannot query for a list of items without an explicit Query grant.
export AWS_REGION=ap-south-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export TABLE_NAME=ddb-lab-invoices
# Create IAM role for Lambda to access DynamoDB
cat > /tmp/lambda-trust.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name ddb-lab-lambda-role \
--assume-role-policy-document file:///tmp/lambda-trust.json
# Attach CloudWatch Logs access
aws iam attach-role-policy \
--role-name ddb-lab-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Create scoped DynamoDB policy -- only the permissions this lab needs
cat > /tmp/ddb-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:BatchWriteItem",
"dynamodb:BatchGetItem",
"dynamodb:TransactWriteItems",
"dynamodb:DescribeTable"
],
"Resource": [
"arn:aws:dynamodb:$AWS_REGION:$ACCOUNT_ID:table/$TABLE_NAME",
"arn:aws:dynamodb:$AWS_REGION:$ACCOUNT_ID:table/$TABLE_NAME/index/*"
]
}
]
}
EOF
aws iam put-role-policy \
--role-name ddb-lab-lambda-role \
--policy-name DynamoDBLabAccess \
--policy-document file:///tmp/ddb-policy.json
export LAMBDA_ROLE_ARN=$(aws iam get-role \
--role-name ddb-lab-lambda-role \
--query 'Role.Arn' --output text)
echo "Lambda Role ARN: $LAMBDA_ROLE_ARN"
sleep 10 # IAM propagationCheckpoint: aws iam get-role-policy --role-name ddb-lab-lambda-role --policy-name DynamoDBLabAccess returns the policy document with all 9 actions.
Access pattern thinking
What's happening here
This is the most important exercise in the entire lab. In a relational database, you design a normalised schema and write whatever SQL you need later. DynamoDB is the opposite, you enumerate every way your application will read data, then design your table to serve those patterns. If you don't know your access patterns before creating the table, you will end up redesigning it. You cannot add an LSI after table creation. You cannot easily change your primary key. Migrations are expensive because there's no ALTER TABLE, you create a new table and backfill. For the invoice service we're building in this lab, the access patterns are:
- Get a single invoice by invoice ID → needs
invoiceIdas PK - Get all invoices for a customer → needs to query by
customerId - Get invoices for a customer within a date range → needs
customerIdPK +createdAtSK - Get all invoices with status PENDING → needs to query by
status - Get the latest N invoices for a customer → same as pattern 3, sorted descending
Patterns 1 is served by the base table. Patterns 2, 3, 5 are served by a GSI with PK=customerId SK=createdAt. Pattern 4 is served by a GSI with PK=status. We design the table now with all of this in mind, not after the fact.
# Document your access patterns before creating anything
# This is not just documentation -- it drives every design decision below
cat << 'EOF'
Access Pattern Analysis. Invoice Service
Pattern 1: Get invoice by ID
Read: GetItem
Key: PK=invoiceId
Served by: Base table
Pattern 2: Get all invoices for a customer
Read: Query
Key: PK=customerId
Served by: GSI (customerIndex)
Pattern 3: Get invoices for a customer in date range
Read: Query with SK condition
Key: PK=customerId, SK between date_start and date_end
Served by: GSI (customerIndex) with SK=createdAt
Pattern 4: Get all PENDING invoices
Read: Query
Key: PK=status
Served by: GSI (statusIndex)
WARNING: Low-cardinality PK -- hot partition risk. Covered in Session 3.
Pattern 5: Get latest N invoices for a customer
Read: Query with ScanIndexForward=false, Limit=N
Key: PK=customerId, SK descending
Served by: GSI (customerIndex) -- same index as pattern 2/3
EOFObserve: five access patterns, two GSIs, zero Scans. Every read pattern has a specific key to use. If you can't answer "which index serves this pattern?", you're not ready to create the table.
Break It: IAM & Access Pattern Thinking
Break 1: GetItem without Query, silent partial access
# Create a minimal role with only GetItem -- no Query
cat > /tmp/getitem-only-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:$AWS_REGION:$ACCOUNT_ID:table/$TABLE_NAME"
}]
}
EOF
# Simulate what happens when a Lambda with only GetItem tries to Query
# (We'll test this properly in Session 1 after the table exists)
echo "Role with GetItem-only will fail on Query with:"
echo "AccessDeniedException: User is not authorized to perform dynamodb:Query"
echo "This error surfaces at runtime, not at deploy time. Easy to miss in testing."IAM errors in DynamoDB surface at request time, not at role creation time. You can deploy a Lambda with an incomplete policy and only discover the missing permission when that specific code path runs in production. Always test every access pattern, not just the happy path.
Break 2: Table name typo in resource ARN
# Policy with a typo in the table name
cat > /tmp/typo-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:$AWS_REGION:$ACCOUNT_ID:table/ddb-lab-invoicess"
}]
}
EOF
echo "Policy created successfully -- IAM does not validate that the table exists."
echo "The error only appears when you try to call DynamoDB:"
echo "AccessDeniedException: ...is not authorized to perform dynamodb:PutItem on resource ddb-lab-invoices"
echo "Note: the error shows the ACTUAL table name, not the policy ARN -- easy to miss."IAM accepts resource ARNs with typos. There's no validation that the resource exists. The mismatch surfaces only at runtime as AccessDeniedException. Always verify IAM policies by actually calling the API, not just by reading the policy document.
Tables, Items, and the Data Model
Goal
Create a DynamoDB table with the right key design. Write, read, update, and delete items via CLI. Understand the difference between GetItem, Query, and Scan, and why Scan is almost always wrong.
Estimated time: 75 min
The DynamoDB data model
What's happening here
DynamoDB organises data around three concepts:
- Partition Key (PK): a hash of this value determines which physical partition stores your item. All items with the same PK are co-located on the same partition. Choose a PK with high cardinality (many distinct values) to distribute data evenly.
- Sort Key (SK): optional. Within a partition, items are sorted by SK. Enables range queries: give me all items in this partition where SK begins with
INVOICE#or SK between two dates. - Item (a collection of attributes. No fixed schema) items in the same table can have completely different attributes. Only PK (and SK if defined) are mandatory on every item.
- Attributes: typed:
S(string),N(number),B(binary),BOOL,NULL,L(list),M(map),SS(string set),NS(number set).
Capacity modes:
- On-Demand: pay per request. No capacity planning. Ideal for labs and unpredictable traffic. Always Free tier covers 25 WCU + 25 RCU per month.
- Provisioned: you set RCU/WCU. Cheaper at sustained, predictable throughput. Auto-scaling available.
Create the table
# Create the Invoices table
# PK: invoiceId (string) -- unique per invoice
# SK: not used on base table -- access pattern 1 only needs PK
# GSIs will be added in Session 3
aws dynamodb create-table \
--table-name $TABLE_NAME \
--attribute-definitions \
AttributeName=invoiceId,AttributeType=S \
AttributeName=customerId,AttributeType=S \
AttributeName=createdAt,AttributeType=S \
AttributeName=status,AttributeType=S \
--key-schema \
AttributeName=invoiceId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--global-secondary-indexes \
'[
{
"IndexName": "customerIndex",
"KeySchema": [
{"AttributeName": "customerId", "KeyType": "HASH"},
{"AttributeName": "createdAt", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
},
{
"IndexName": "statusIndex",
"KeySchema": [
{"AttributeName": "status", "KeyType": "HASH"},
{"AttributeName": "createdAt", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}
]' \
--region $AWS_REGION
# Wait for table to be ACTIVE
echo "Waiting for table to be ACTIVE..."
aws dynamodb wait table-exists --table-name $TABLE_NAME --region $AWS_REGION
# Verify
aws dynamodb describe-table \
--table-name $TABLE_NAME \
--region $AWS_REGION \
--query 'Table.{Name:TableName,Status:TableStatus,KeySchema:KeySchema,GSIs:GlobalSecondaryIndexes[*].IndexName}'Checkpoint: table status is ACTIVE. Two GSIs (customerIndex, statusIndex) are visible.
Write and read items
What's happening here
DynamoDB's type system is explicit in the CLI, every attribute value is wrapped in a type descriptor: {"S": "value"} for string, {"N": "42"} for number (note: numbers are passed as strings in JSON but stored as numbers), {"BOOL": true}, {"NULL": true}. PutItem is a full overwrite, if an item with the same PK already exists, it replaces it entirely. If you want to update specific attributes without overwriting the whole item, use UpdateItem. GetItem requires the exact PK (and SK if the table has one). It returns exactly one item or nothing. It cannot return multiple items.