← Back to DevBytes

DynamoDB Best Practices: Cost, Security, and Performance

Introduction to DynamoDB Best Practices

Amazon DynamoDB is a fully managed NoSQL database service that delivers consistent single-digit millisecond performance at any scale. While its serverless nature removes operational overhead, realizing its full potential requires deliberate attention to cost efficiency, security posture, and performance tuning. A poorly modeled table, a broad IAM policy, or an incorrect capacity mode can lead to runaway bills, data leaks, or throttled requests. This tutorial covers the most impactful practices across all three dimensions, with actionable code examples you can apply immediately.

Cost Optimization

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

DynamoDB charges for reading, writing, and storing data. The pricing model splits into two main paths: provisioned capacity (predictable workloads) and on-demand capacity (variable or unknown traffic). Optimizing cost is about aligning your usage with the right billing mode, reducing wasted throughput, and managing storage footprint.

Choosing the Right Capacity Mode

Provisioned capacity lets you specify read capacity units (RCUs) and write capacity units (WCUs). It suits steady-state or predictable traffic and benefits from reserved capacity discounts (up to 75% for a 1-year commitment). On-demand capacity scales automatically with request volume and eliminates capacity planning, but each request costs about 5× more than provisioned RCU/WCU. Use on-demand for development, testing, variable workloads, or when you cannot forecast traffic.

A common anti-pattern is starting with on-demand and forgetting to switch to provisioned after the traffic pattern stabilizes. If your application has a predictable daily cycle, provisioned capacity with Auto Scaling often yields significant savings.


# Switching table billing mode with boto3 (Python)
import boto3

client = boto3.client('dynamodb')

# Move from on-demand to provisioned (requires specifying initial capacity)
response = client.update_table(
    TableName='Orders',
    BillingMode='PROVISIONED',
    ProvisionedThroughput={
        'ReadCapacityUnits': 5,
        'WriteCapacityUnits': 5
    }
)
print("Billing mode switched to PROVISIONED")

Auto Scaling for Provisioned Capacity

Auto Scaling dynamically adjusts provisioned RCUs and WCUs within your defined boundaries. It uses a target utilization percentage (e.g., 70%) and scales out/in based on CloudWatch metrics. This avoids manual tuning and reduces throttling while keeping costs aligned with actual demand. Always enable Auto Scaling on production tables, and set a reasonable minimum capacity to prevent scaling delays from impacting cold starts.


# Register scalable target and scaling policy for a DynamoDB table
import boto3

autoscaling = boto3.client('application-autoscaling')

# Register the table as scalable (WCUs)
autoscaling.register_scalable_target(
    ServiceNamespace='dynamodb',
    ResourceId='table/Orders',  # or 'index/Orders/my-index' for a GSI
    ScalableDimension='dynamodb:table:WriteCapacityUnits',
    MinCapacity=5,
    MaxCapacity=100
)

# Attach a target tracking scaling policy
autoscaling.put_scaling_policy(
    PolicyName='OrdersWriteScalingPolicy',
    ServiceNamespace='dynamodb',
    ResourceId='table/Orders',
    ScalableDimension='dynamodb:table:WriteCapacityUnits',
    PolicyType='TargetTrackingScaling',
    TargetTrackingScalingPolicyConfiguration={
        'TargetValue': 70.0,   # 70% target utilization
        'ScaleInCooldown': 60,
        'ScaleOutCooldown': 60
    }
)
print("Auto Scaling policy applied")

Reducing Storage Costs with TTL

Time-to-Live (TTL) automatically removes items after a timestamp attribute expires. It’s free to use and can dramatically cut storage and write costs for ephemeral data like session tokens, logs, or cache entries. Define an attribute that holds the expiration epoch (in seconds), enable TTL on the table, and DynamoDB deletes expired items within a few days (free of charge).


# Enable TTL on an existing table using the 'expires_at' attribute
client.update_time_to_live(
    TableName='UserSessions',
    TimeToLiveSpecification={
        'Enabled': True,
        'AttributeName': 'expires_at'
    }
)

# When putting an item, set the expiry to 24 hours from now
import time
expiry_epoch = int(time.time()) + 86400  # 24 hours in seconds

table = boto3.resource('dynamodb').Table('UserSessions')
table.put_item(
    Item={
        'session_id': 'abc-123',
        'user_id': 'user456',
        'data': '...',
        'expires_at': expiry_epoch
    }
)

Minimizing Item Size and Avoiding Scans

DynamoDB charges for the size of items in read operations (4 KB per RCU for strongly consistent reads, 1 KB per WCU). Keep items as small as possible, and compress large blobs before storing. Additionally, Scan operations consume RCUs based on the total scanned data, not just returned items. Prefer Query with indexes to retrieve only what you need. A Scan on a large table can be extremely expensive and slow.

Security Best Practices

DynamoDB security revolves around IAM policies, encryption, network isolation, and auditing. Because the database holds critical application data, a misconfigured policy can expose records to unintended users or services.

Fine-Grained IAM Policies

Instead of granting broad dynamodb:* permissions, scope policies to specific tables, actions, and even partition keys or attributes. Use IAM condition keys like dynamodb:LeadingKeys to restrict row-level access (e.g., users can only access their own data). Attribute-level conditions can further limit which attributes a caller sees.


{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:Query"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/UserData",
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:Attributes": ["user_id", "email", "created_date"]
        },
        "StringEquals": {
          "dynamodb:LeadingKeys": ["${cognito-identity.amazonaws.com:sub}"]
        }
      }
    }
  ]
}

This policy allows only the Cognito-authenticated identity to access its own row (based on partition key) and limits visible attributes to the listed ones. Always test policies with the IAM Policy Simulator.

Encryption at Rest and In Transit

DynamoDB encrypts data at rest by default using AWS-owned KMS keys. For regulated workloads, switch to customer-managed keys (CMKs) to control key rotation and audit access via CloudTrail. All communication between your application and DynamoDB is encrypted over TLS 1.2+ (HTTPS). If you use DynamoDB Streams or DAX, those channels are also encrypted. Ensure your SDK clients use secure endpoints and do not disable certificate verification.


# Create a table with a customer-managed KMS key
client.create_table(
    TableName='SecureOrders',
    KeySchema=[
        {'AttributeName': 'order_id', 'KeyType': 'HASH'}
    ],
    AttributeDefinitions=[
        {'AttributeName': 'order_id', 'AttributeType': 'S'}
    ],
    BillingMode='PAY_PER_REQUEST',
    SSESpecification={
        'Enabled': True,
        'SSEType': 'KMS',
        'KMSMasterKeyId': 'arn:aws:kms:us-east-1:123456789012:key/abc-1234-...'
    }
)

Network Isolation with VPC Endpoints

To keep DynamoDB traffic inside the AWS network and avoid internet exposure, create a VPC endpoint (Gateway Endpoint) for DynamoDB. Associate it with your VPC and restrict access via endpoint policies. This also eliminates NAT gateway costs for DynamoDB traffic from private subnets.


# Create a DynamoDB VPC endpoint with a restrictive policy
aws ec2 create-vpc-endpoint \
    --vpc-id vpc-0abc1234 \
    --service-name com.amazonaws.us-east-1.dynamodb \
    --route-table-ids rtb-0def5678 \
    --policy-document '{
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": "*",
          "Action": "dynamodb:*",
          "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Orders"
        }
      ]
    }'

Monitoring and Auditing

Enable AWS CloudTrail to log all DynamoDB control plane operations (CreateTable, UpdateTable, etc.). For data plane audit, use DynamoDB Streams with Lambda to capture every item change. GuardDuty can detect suspicious database access patterns. Additionally, set up CloudWatch alarms on throttling events (ThrottledRequests metric) and anomalous read/write patterns to detect misuse or misconfiguration.

Performance Optimization

Performance in DynamoDB depends heavily on data modeling, access patterns, and proper capacity configuration. A well-designed single-table design can handle multiple entity types efficiently, while a naive schema can cause hot partitions and throttling.

Single-Table Design and Key Selection

Instead of using a separate table per entity, use a single table with a generic partition key (PK) and sort key (SK) that overload different entity types. For example, PK = USER#123 and SK = PROFILE# stores user profile, while SK = ORDER#2024-01-01 stores an order. This technique groups related items and allows efficient queries by using sort key prefixes and filters.


# Example single-table schema: PK = user_id, SK = entity type + date
# Query all orders for a user sorted by creation date

table = boto3.resource('dynamodb').Table('AppTable')
response = table.query(
    KeyConditionExpression=Key('PK').eq('USER#123') & Key('SK').begins_with('ORDER#'),
    ScanIndexForward=False,  # descending by SK (date)
    Limit=10
)
# Returns up to 10 most recent orders for user 123

Avoiding Hot Partitions

A hot partition occurs when a single partition key receives far more requests than others, causing throttling even when overall capacity seems sufficient. Mitigation strategies include:

Leveraging Global Secondary Indexes (GSIs)

GSIs allow querying by alternative keys without duplicating data manually. They consume their own RCUs/WCUs and must be provisioned or auto-scaled separately. Use sparse indexes (only items that have the indexed attribute) to keep indexes lean. Avoid over-indexing—each GSI adds write cost and storage.


# Create a GSI on a table for querying by email
client.update_table(
    TableName='AppTable',
    AttributeDefinitions=[
        {'AttributeName': 'email', 'AttributeType': 'S'}
    ],
    GlobalSecondaryIndexUpdates=[
        {
            'Create': {
                'IndexName': 'EmailIndex',
                'KeySchema': [
                    {'AttributeName': 'email', 'KeyType': 'HASH'}
                ],
                'Projection': {'ProjectionType': 'ALL'},
                'ProvisionedThroughput': {
                    'ReadCapacityUnits': 5,
                    'WriteCapacityUnits': 5
                }
            }
        }
    ]
)

Query Instead of Scan

Query operations locate items by partition key (and optional sort key conditions) with logarithmic efficiency. Scan iterates over every item in the table, consuming significant RCUs. In almost all real-world access patterns, a query on the base table or a GSI outperforms a scan. Use FilterExpression on a query to further narrow results without extra cost (filtering is applied after data retrieval, but the read remains scoped to the key condition).


# Efficient query with filter
response = table.query(
    KeyConditionExpression=Key('PK').eq('PRODUCT#123'),
    FilterExpression=Attr('status').eq('active') & Attr('price').lt(50),
    Limit=20
)

Batch Operations and Parallelization

Use BatchGetItem to retrieve multiple items in a single round-trip, respecting the 16 MB response limit and 100-item max per batch. For large-scale reads, parallelize multiple segments with Scan using Segment and TotalSegments parameters, but only when necessary (e.g., data export).


# Batch get items across multiple keys
response = client.batch_get_item(
    RequestItems={
        'AppTable': {
            'Keys': [
                {'PK': {'S': 'USER#123'}, 'SK': {'S': 'PROFILE#'}},
                {'PK': {'S': 'USER#123'}, 'SK': {'S': 'ORDER#2024-01-01'}}
            ],
            'ConsistentRead': True
        }
    }
)

Amazon DynamoDB Accelerator (DAX)

DAX is a fully managed in-memory cache that reduces read latency to microseconds for cached items. It’s ideal for read-heavy, latency-sensitive applications like leaderboards or real-time bidding. DAX sits between your application and DynamoDB, caching items and query results. It requires VPC configuration and is not a fit for write-heavy workloads. Use DAX when your reads have a cacheable pattern and microsecond latency is required.


# Using DAX client in Python (requires DAX SDK)
import boto3
import amazondax

# Replace normal DynamoDB client with DAX endpoint
client = amazondax.AmazonDaxClient(
    endpoints=['your-dax-cluster.abc.cache.amazonaws.com:8111']
)
# Now use client.get_item, query, etc. as usual
response = client.get_item(
    TableName='Leaderboard',
    Key={'game_id': {'S': 'game1'}, 'player_id': {'S': 'player42'}}
)

Conclusion

Mastering DynamoDB means weaving cost, security, and performance into your design from day one. Start with a clear access-pattern-driven data model, choose the capacity mode that matches your workload, enforce least-privilege IAM policies, and protect data with encryption and network controls. Continuously monitor throttling and storage metrics, and leverage TTL and Auto Scaling to keep costs in check. By applying these practices, you transform DynamoDB from a simple key-value store into a robust, scalable, and cost-effective backbone for modern serverless applications.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles