DEV Community

Cover image for DynamoDB On-Demand is Bleeding You Dry: Switch to Provisioned and Save 60% πŸ’°
Suhas Mallesh
Suhas Mallesh

Posted on • Edited on

DynamoDB On-Demand is Bleeding You Dry: Switch to Provisioned and Save 60% πŸ’°

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!) πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

πŸ“Š 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
Enter fullscreen mode Exit fullscreen mode

Rule of thumb:

min_capacity = average hourly consumption
max_capacity = 2Γ— peak hourly consumption
scaling_target = 70%
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ“ˆ 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
Enter fullscreen mode Exit fullscreen mode

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!) πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

🎯 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)