Skip to main content

DynamoDB Design Patterns for Real Applications

Practical DynamoDB patterns - single table design, access patterns, GSIs, and avoiding common mistakes.

DynamoDB requires a different mindset than relational databases. Here’s what I’ve learned designing tables for production.

The Mindset Shift #

In SQL, you model data first, then write queries. In DynamoDB, you start with access patterns.

Ask: “What questions will I need to answer?”

Single Table Design #

Instead of multiple tables, use one table with different item types:

PK              | SK                  | Type    | Data
----------------|---------------------|---------|------------
USER#123        | PROFILE             | User    | {name, email}
USER#123        | ORDER#456           | Order   | {total, status}
USER#123        | ORDER#456#ITEM#1    | Item    | {product, qty}
ORDER#456       | ORDER#456           | Order   | {userId, total}

This enables fetching related data in a single query.

Access Patterns #

Pattern 1: Get User Profile #

result, _ := client.GetItem(ctx, &dynamodb.GetItemInput{
    TableName: aws.String("MyTable"),
    Key: map[string]types.AttributeValue{
        "PK": &types.AttributeValueMemberS{Value: "USER#123"},
        "SK": &types.AttributeValueMemberS{Value: "PROFILE"},
    },
})

Pattern 2: Get User’s Orders #

result, _ := client.Query(ctx, &dynamodb.QueryInput{
    TableName:              aws.String("MyTable"),
    KeyConditionExpression: aws.String("PK = :pk AND begins_with(SK, :sk)"),
    ExpressionAttributeValues: map[string]types.AttributeValue{
        ":pk": &types.AttributeValueMemberS{Value: "USER#123"},
        ":sk": &types.AttributeValueMemberS{Value: "ORDER#"},
    },
})

Pattern 3: Get Order with Items #

result, _ := client.Query(ctx, &dynamodb.QueryInput{
    TableName:              aws.String("MyTable"),
    KeyConditionExpression: aws.String("PK = :pk AND begins_with(SK, :sk)"),
    ExpressionAttributeValues: map[string]types.AttributeValue{
        ":pk": &types.AttributeValueMemberS{Value: "USER#123"},
        ":sk": &types.AttributeValueMemberS{Value: "ORDER#456"},
    },
})
// Returns order AND its items in one query

Global Secondary Indexes (GSI) #

For access patterns that don’t fit the main table’s keys:

GSI1PK          | GSI1SK              | (projected attributes)
----------------|---------------------|------------------------
ORDER#PENDING   | 2024-01-15#ORDER#1  | {orderId, userId}
ORDER#SHIPPED   | 2024-01-14#ORDER#2  | {orderId, userId}

Query orders by status:

result, _ := client.Query(ctx, &dynamodb.QueryInput{
    TableName: aws.String("MyTable"),
    IndexName: aws.String("GSI1"),
    KeyConditionExpression: aws.String("GSI1PK = :status"),
    ExpressionAttributeValues: map[string]types.AttributeValue{
        ":status": &types.AttributeValueMemberS{Value: "ORDER#PENDING"},
    },
})

Transactions #

For operations that must succeed or fail together:

_, err := client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{
    TransactItems: []types.TransactWriteItem{
        {
            Update: &types.Update{
                TableName: aws.String("MyTable"),
                Key: map[string]types.AttributeValue{
                    "PK": &types.AttributeValueMemberS{Value: "USER#123"},
                    "SK": &types.AttributeValueMemberS{Value: "PROFILE"},
                },
                UpdateExpression: aws.String("SET balance = balance - :amount"),
                ConditionExpression: aws.String("balance >= :amount"),
                ExpressionAttributeValues: map[string]types.AttributeValue{
                    ":amount": &types.AttributeValueMemberN{Value: "100"},
                },
            },
        },
        {
            Put: &types.Put{
                TableName: aws.String("MyTable"),
                Item: map[string]types.AttributeValue{
                    "PK": &types.AttributeValueMemberS{Value: "USER#123"},
                    "SK": &types.AttributeValueMemberS{Value: "TXN#" + txnID},
                    // ... transaction details
                },
            },
        },
    },
})

Time-to-Live (TTL) #

Automatically expire items:

type Session struct {
    PK        string `dynamodbav:"PK"`
    SK        string `dynamodbav:"SK"`
    UserID    string `dynamodbav:"userId"`
    ExpiresAt int64  `dynamodbav:"ttl"` // Unix timestamp
}

// Item will be deleted after ExpiresAt
session := Session{
    PK:        "SESSION#abc123",
    SK:        "SESSION#abc123",
    UserID:    "user123",
    ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
}

Pagination #

Handle large result sets:

func getAllOrders(ctx context.Context, userID string) ([]Order, error) {
    var orders []Order
    var lastKey map[string]types.AttributeValue
    
    for {
        input := &dynamodb.QueryInput{
            TableName:              aws.String("MyTable"),
            KeyConditionExpression: aws.String("PK = :pk AND begins_with(SK, :sk)"),
            ExpressionAttributeValues: map[string]types.AttributeValue{
                ":pk": &types.AttributeValueMemberS{Value: "USER#" + userID},
                ":sk": &types.AttributeValueMemberS{Value: "ORDER#"},
            },
            ExclusiveStartKey: lastKey,
            Limit:             aws.Int32(100),
        }
        
        result, err := client.Query(ctx, input)
        if err != nil {
            return nil, err
        }
        
        // Process items...
        orders = append(orders, parseOrders(result.Items)...)
        
        if result.LastEvaluatedKey == nil {
            break
        }
        lastKey = result.LastEvaluatedKey
    }
    
    return orders, nil
}

Common Mistakes #

1. Hot Partitions #

Avoid keys that concentrate traffic:

// Bad: All writes go to same partition
PK: "ORDERS"

// Good: Distribute across partitions
PK: "ORDERS#2024-01-15"
// or
PK: "ORDERS#" + hash(orderID) % 10

2. Scan Operations #

Scans read every item - expensive and slow:

// Bad: Scanning for orders by status
client.Scan(ctx, &dynamodb.ScanInput{
    FilterExpression: aws.String("status = :s"),
})

// Good: Use GSI with status as partition key
client.Query(ctx, &dynamodb.QueryInput{
    IndexName: aws.String("StatusIndex"),
    KeyConditionExpression: aws.String("status = :s"),
})

3. Large Items #

DynamoDB has 400KB item limit. Store large data in S3:

type Document struct {
    PK          string `dynamodbav:"PK"`
    SK          string `dynamodbav:"SK"`
    S3Location  string `dynamodbav:"s3Location"` // s3://bucket/path
    Metadata    string `dynamodbav:"metadata"`
}

Cost Optimization #

On-Demand vs Provisioned #

  • On-Demand: Variable traffic, unpredictable load
  • Provisioned: Steady traffic, predictable costs

Use Projections in GSIs #

Only project attributes you need:

GlobalSecondaryIndexes:
  - IndexName: StatusIndex
    KeySchema:
      - AttributeName: status
        KeyType: HASH
    Projection:
      ProjectionType: INCLUDE
      NonKeyAttributes:
        - orderId
        - createdAt

Key Takeaways #

  1. Design for access patterns, not entities
  2. Single table design reduces queries
  3. Use GSIs sparingly - they duplicate data
  4. Avoid hot partitions with good key design
  5. Never scan in production

DynamoDB rewards upfront design effort with massive scale and predictable performance.