DynamoDB On-Demand costs 6x more per request than Provisioned. If your traffic is predictable, youβre massively overpaying. Hereβs the Terraform switch with auto-scaling.
On-Demand mode sounds great: No capacity planning, pay per request, automatic scaling.
On-Demand mode is expensive: $1.25 per million writes, $0.25 per million reads.
Provisioned mode with auto-scaling: $0.00065 per write unit-hour, $0.00013 per read unit-hour.
For predictable workloads, On-Demand costs 6x more.
10M writes/month + 50M reads/month:
On-Demand:
Writes: 10M Γ $1.25/1M = $12.50
Reads: 50M Γ $0.25/1M = $12.50
Total: $25.00/month
Provisioned (100 WCU, 500 RCU):
Writes: 100 Γ $0.00065 Γ 730hrs = $4.75
Reads: 500 Γ $0.00013 Γ 730hrs = $4.75
Total: $9.50/month
Savings: $15.50/month = $186/year (62% reduction!) π
Let me show you how to switch with Terraform β including auto-scaling so you never over-provision.
πΈ When On-Demand Hurts You
On-Demand is designed for:
- Unpredictable, spiky traffic
- New tables (unknown patterns)
- Low-traffic tables (< 1M requests/month)
But most production tables have:
- Predictable traffic patterns
- Consistent daily/weekly cycles
- Known peak and off-peak periods
If your traffic is consistent, youβre donating money to AWS.
π― The Smart Strategy
Use Provisioned + Auto-Scaling:
- Set min/max capacity
- Auto-scales based on utilization target (70%)
- Handles traffic spikes automatically
- Costs 60-70% less than On-Demand
Best of both worlds: automatic scaling + predictable pricing.
π οΈ Terraform Implementation
Switch from On-Demand to Provisioned + Auto-Scaling
# dynamodb-provisioned.tf
resource "aws_dynamodb_table" "orders" {
name = "orders"
billing_mode = "PROVISIONED" # Changed from PAY_PER_REQUEST
read_capacity = 50 # Starting point
write_capacity = 20 # Starting point
hash_key = "orderId"
range_key = "createdAt"
attribute {
name = "orderId"
type = "S"
}
attribute {
name = "createdAt"
type = "S"
}
tags = {
Name = "orders-table"
}
}
# Auto-scaling for reads
resource "aws_appautoscaling_target" "read" {
max_capacity = 500
min_capacity = 10
resource_id = "table/${aws_dynamodb_table.orders.name}"
scalable_dimension = "dynamodb:table:ReadCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "read" {
name = "orders-read-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.read.resource_id
scalable_dimension = aws_appautoscaling_target.read.scalable_dimension
service_namespace = aws_appautoscaling_target.read.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBReadCapacityUtilization"
}
target_value = 70.0 # Scale when 70% utilized
}
}
# Auto-scaling for writes
resource "aws_appautoscaling_target" "write" {
max_capacity = 200
min_capacity = 5
resource_id = "table/${aws_dynamodb_table.orders.name}"
scalable_dimension = "dynamodb:table:WriteCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "write" {
name = "orders-write-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.write.resource_id
scalable_dimension = aws_appautoscaling_target.write.scalable_dimension
service_namespace = aws_appautoscaling_target.write.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBWriteCapacityUtilization"
}
target_value = 70.0
}
}
Reusable Module
# modules/dynamodb-with-autoscaling/main.tf
variable "table_name" {
description = "DynamoDB table name"
type = string
}
variable "hash_key" {
description = "Hash key attribute name"
type = string
}
variable "range_key" {
description = "Range key attribute name (optional)"
type = string
default = null
}
variable "attributes" {
description = "List of attribute definitions"
type = list(object({
name = string
type = string
}))
}
variable "read_capacity" {
description = "Initial read capacity units"
type = number
default = 10
}
variable "write_capacity" {
description = "Initial write capacity units"
type = number
default = 5
}
variable "read_max_capacity" {
description = "Maximum read capacity"
type = number
default = 100
}
variable "write_max_capacity" {
description = "Maximum write capacity"
type = number
default = 50
}
variable "scaling_target" {
description = "Target utilization % before scaling"
type = number
default = 70
}
resource "aws_dynamodb_table" "this" {
name = var.table_name
billing_mode = "PROVISIONED"
read_capacity = var.read_capacity
write_capacity = var.write_capacity
hash_key = var.hash_key
range_key = var.range_key
dynamic "attribute" {
for_each = var.attributes
content {
name = attribute.value.name
type = attribute.value.type
}
}
point_in_time_recovery {
enabled = true
}
tags = {
Name = var.table_name
ManagedBy = "terraform"
}
}
# Read auto-scaling
resource "aws_appautoscaling_target" "read" {
max_capacity = var.read_max_capacity
min_capacity = var.read_capacity
resource_id = "table/${aws_dynamodb_table.this.name}"
scalable_dimension = "dynamodb:table:ReadCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "read" {
name = "${var.table_name}-read-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.read.resource_id
scalable_dimension = aws_appautoscaling_target.read.scalable_dimension
service_namespace = aws_appautoscaling_target.read.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBReadCapacityUtilization"
}
target_value = var.scaling_target
scale_in_cooldown = 300 # 5 mins before scaling in
scale_out_cooldown = 60 # 1 min before scaling out
}
}
# Write auto-scaling
resource "aws_appautoscaling_target" "write" {
max_capacity = var.write_max_capacity
min_capacity = var.write_capacity
resource_id = "table/${aws_dynamodb_table.this.name}"
scalable_dimension = "dynamodb:table:WriteCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "write" {
name = "${var.table_name}-write-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.write.resource_id
scalable_dimension = aws_appautoscaling_target.write.scalable_dimension
service_namespace = aws_appautoscaling_target.write.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBWriteCapacityUtilization"
}
target_value = var.scaling_target
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
output "table_name" {
value = aws_dynamodb_table.this.name
}
output "table_arn" {
value = aws_dynamodb_table.this.arn
}
Usage
module "orders_table" {
source = "./modules/dynamodb-with-autoscaling"
table_name = "orders"
hash_key = "orderId"
range_key = "createdAt"
attributes = [
{ name = "orderId", type = "S" },
{ name = "createdAt", type = "S" }
]
read_capacity = 50
write_capacity = 20
read_max_capacity = 500
write_max_capacity = 200
scaling_target = 70
}
module "products_table" {
source = "./modules/dynamodb-with-autoscaling"
table_name = "products"
hash_key = "productId"
attributes = [
{ name = "productId", type = "S" }
]
read_capacity = 100
write_capacity = 10
read_max_capacity = 1000
write_max_capacity = 100
}
π Right-Sizing Your Capacity
How to find the right starting values:
# Check current On-Demand consumption
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB \
--metric-name ConsumedReadCapacityUnits \
--dimensions Name=TableName,Value=orders \
--start-time $(date -d '7 days ago' -Iso) \
--end-time $(date -Iso) \
--period 3600 \
--statistics Average,Maximum
# Use Maximum to determine your peak
# Set min_capacity = average usage
# Set max_capacity = 2x peak
Rule of thumb:
min_capacity = average hourly consumption
max_capacity = 2Γ peak hourly consumption
scaling_target = 70%
π‘ Pro Tips
1. Use Reserved Capacity for Extra Savings
Once on Provisioned, buy Reserved Capacity for an additional 50%+ discount:
# Purchase in AWS Console: DynamoDB β Reserved Capacity
# 1-year: up to 50% off
# 3-year: up to 76% off
Combined with Provisioned: total savings up to 80% vs On-Demand! π
2. Monitor Throttling
resource "aws_cloudwatch_metric_alarm" "throttle" {
alarm_name = "${var.table_name}-throttling"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "SystemErrors"
namespace = "AWS/DynamoDB"
period = 60
statistic = "Sum"
threshold = 5
dimensions = {
TableName = var.table_name
}
}
If you see throttling, increase min_capacity or lower scaling_target.
3. Global Secondary Indexes Need Their Own Scaling
# GSI auto-scaling - must be done separately
resource "aws_appautoscaling_target" "gsi_read" {
max_capacity = 100
min_capacity = 5
resource_id = "table/${aws_dynamodb_table.this.name}/index/status-index"
scalable_dimension = "dynamodb:index:ReadCapacityUnits"
service_namespace = "dynamodb"
}
4. Keep Low-Traffic Tables On-Demand
# On-Demand still makes sense for:
resource "aws_dynamodb_table" "config" {
name = "app-config"
billing_mode = "PAY_PER_REQUEST" # < 1M requests/month
# Not worth provisioning for low-traffic tables
}
β οΈ Common Gotchas
1. Scaling Has a Delay (~2 Minutes)
Auto-scaling reacts in ~2 minutes. For sudden spikes:
# Buffer capacity to handle initial spikes
read_capacity = 50 # Set 20-30% above average
write_capacity = 20
2. Can Only Switch Billing Mode Once Per Day
Plan your migration carefully β you canβt switch back and forth rapidly.
3. Burst Traffic Needs Buffer
# If you have predictable daily spikes, set higher min
# Example: Daily batch job at 9 AM doubles write load
write_capacity = 40 # Set to handle the batch
π Real-World Savings
SaaS app with 5 DynamoDB tables:
Before (all On-Demand):
Users table: 30M reads/mo = $7.50
Sessions table: 50M reads/mo = $12.50
Orders table: 20M writes/mo = $25.00
Products table: 80M reads/mo = $20.00
Events table: 40M writes/mo = $50.00
Total: $115.50/month
Annual: $1,386
After (Provisioned + Auto-Scaling):
Users: 200 RCU Γ $0.00013 Γ 730hrs = $19.00
Sessions: 300 RCU Γ $0.00013 Γ 730hrs = $28.00
Orders: 100 WCU Γ $0.00065 Γ 730hrs = $47.45
Products: 500 RCU Γ $0.00013 Γ 730hrs = $47.45
Events: 200 WCU Γ $0.00065 Γ 730hrs = $94.90
Total: $47.80/month
Annual: $573.60
Savings: $67.70/month = $812.40/year (58% reduction!) π
π― Quick Decision Guide
| Scenario | Billing Mode |
|---|---|
| New table, unknown traffic | On-Demand |
| Traffic < 1M requests/month | On-Demand |
| Consistent, predictable traffic | Provisioned β |
| Production tables > 3 months old | Provisioned β |
| Cost optimization is priority | Provisioned β |
| Traffic spikes 10x+ randomly | On-Demand |
π― Summary
On-Demand: Great for starting out, expensive at scale.
Provisioned + Auto-Scaling: 60-70% cheaper, equally flexible, just requires initial setup.
The math is simple:
- Predictable traffic? β Provisioned
- Add auto-scaling β peace of mind
- Optional: Reserved Capacity β even more savings
Implementation:
- 5 minutes to update Terraform
- Auto-scaling handles the rest
- Monitor throttling for first week
Stop overpaying for On-Demand DynamoDB. Switch to Provisioned + Auto-Scaling and save 60%. π
Switched from On-Demand to Provisioned? Share your savings in the comments! π¬
Follow for more AWS cost optimization with Terraform! β‘
Top comments (0)