devuplabs.cloud
PreviewLab6 hours

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

0 of 5 checked

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 item
  • dynamodb:GetItem: read a single item by exact PK (+ SK)
  • dynamodb:UpdateItem: modify specific attributes of an existing item
  • dynamodb:DeleteItem: remove an item
  • dynamodb:Query: read multiple items matching a PK value, optionally filtered by SK
  • dynamodb:Scan: read every item in the table (expensive, almost always wrong)
  • dynamodb:BatchWriteItem / dynamodb:BatchGetItem: bulk operations
  • dynamodb: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.

bash
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 propagation

Checkpoint: 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:

  1. Get a single invoice by invoice ID → needs invoiceId as PK
  2. Get all invoices for a customer → needs to query by customerId
  3. Get invoices for a customer within a date range → needs customerId PK + createdAt SK
  4. Get all invoices with status PENDING → needs to query by status
  5. 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.

bash
# 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
EOF

Observe: 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

bash
# 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

bash
# 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

bash
# 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.

Unlock all 24 AWS services & 291+ lab sessions (~180 hours)

Pricing