Amazon DynamoDB – Consider Using TTL on Tables

·

By

Infracost

Amazon DynamoDB – Consider Using TTL on Tables

·

By

Infracost

The DynamoDB provisioned capacity policy recommends running Amazon DynamoDB tables with steady, predictable traffic in provisioned capacity mode instead of on-demand mode, using billing_mode = "PROVISIONED" on the aws_dynamodb_table Terraform resource. At US East (N. Virginia) list prices, fully used provisioned capacity costs about 70% less than the same throughput bought on demand. Apply this policy to production tables whose consumed capacity stays well above idle for most of the day.

Policy at a Glance

Attribute

Detail

Cloud Provider

AWS

Resource Type

Amazon DynamoDB tables

Terraform Attribute

billing_mode, read_capacity, write_capacity on aws_dynamodb_table

Compliant Value

billing_mode = "PROVISIONED" with capacity sized from observed traffic, plus auto scaling

Cost Impact

Steady tables typically cost about half as much on provisioned capacity; spiky or idle tables cost more

Why This Policy Matters

How It Helps Reduce Cloud Costs

Amazon DynamoDB offers two throughput billing models. On-demand capacity mode (PAY_PER_REQUEST) bills per million read and write request units. Provisioned capacity mode bills per read capacity unit (RCU) and write capacity unit (WCU) per hour, whether or not the table uses them.

In US East (N. Virginia), on-demand writes are listed at $0.625 per million write request units and reads at $0.125 per million read request units. Provisioned capacity is listed at $0.00065 per WCU-hour and $0.00013 per RCU-hour.

One WCU running flat out for a month handles about 2.6 million writes. That costs about $0.47 provisioned, versus about $1.64 on demand.

DynamoDB on-demand pricing is effectively a convenience premium. It removes capacity planning, and AWS charges for that convenience on every request.

The premium is worth paying for spiky or unpredictable tables. It is rarely worth paying for production tables that serve the same traffic shape every day.

AWS documentation states that on-demand mode costs less for workloads with average provisioned capacity utilization below 35%. Above that line, a DynamoDB table with steady traffic usually costs less on provisioned capacity.

Potential Savings

Consider a 100 GB DynamoDB table in US East (N. Virginia) that serves a steady average of 500 writes per second and 2,000 strongly consistent reads per second, with items under 1 KB.

On on-demand capacity, that DynamoDB table processes about 1.3 billion writes and 5.3 billion reads per month. Throughput costs about $1,478 per month, plus $25 for storage, for roughly $1,503 in total.

On provisioned capacity sized at a 70% target utilization, the same table needs 715 WCUs and 2,858 RCUs. Throughput costs about $611 per month, plus the same $25 for storage, for roughly $635 in total.

That is a saving of about $868 per month, or about 58%, from one billing_mode change.

These figures are illustrative list-price estimates. They exclude the AWS Free Tier, global table replication, and DynamoDB reserved capacity, which AWS lists at up to 54% further savings on a 1-year term for provisioned capacity.

Implementation Guide

Infrastructure-as-Code Example (Terraform)

The following examples show an Amazon DynamoDB table on on-demand capacity, and the corrected provisioned configuration with auto scaling.

Non-compliant configuration: steady production table on on-demand capacity

resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}
resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}
resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}

This table pays the on-demand premium on every request, even though its traffic is steady around the clock. This is a common source of wasted spend on mature production services.

Compliant configuration: provisioned capacity with target tracking auto scaling

resource "aws_dynamodb_table" "orders" {
  name           = "orders"
  billing_mode   = "PROVISIONED"
  read_capacity  = 2858
  write_capacity = 715
  hash_key       = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }

  lifecycle {
    ignore_changes = [read_capacity, write_capacity]
  }
}

resource "aws_appautoscaling_target" "orders_write" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:WriteCapacityUnits"
  min_capacity       = 500
  max_capacity       = 2000
}

resource "aws_appautoscaling_policy" "orders_write" {
  name               = "orders-write-target-tracking"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders_write.service_namespace
  resource_id        = aws_appautoscaling_target.orders_write.resource_id
  scalable_dimension = aws_appautoscaling_target.orders_write.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBWriteCapacityUtilization"
    }
    target_value = 70
  }
}

resource "aws_appautoscaling_target" "orders_read" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:ReadCapacityUnits"
  min_capacity       = 2000
  max_capacity       = 6000
}

resource "aws_appautoscaling_policy" "orders_read" {
  name               = "orders-read-target-tracking"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders_read.service_namespace
  resource_id        = aws_appautoscaling_target.orders_read.resource_id
  scalable_dimension = aws_appautoscaling_target.orders_read.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBReadCapacityUtilization"
    }
    target_value = 70
  }
}
resource "aws_dynamodb_table" "orders" {
  name           = "orders"
  billing_mode   = "PROVISIONED"
  read_capacity  = 2858
  write_capacity = 715
  hash_key       = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }

  lifecycle {
    ignore_changes = [read_capacity, write_capacity]
  }
}

resource "aws_appautoscaling_target" "orders_write" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:WriteCapacityUnits"
  min_capacity       = 500
  max_capacity       = 2000
}

resource "aws_appautoscaling_policy" "orders_write" {
  name               = "orders-write-target-tracking"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders_write.service_namespace
  resource_id        = aws_appautoscaling_target.orders_write.resource_id
  scalable_dimension = aws_appautoscaling_target.orders_write.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBWriteCapacityUtilization"
    }
    target_value = 70
  }
}

resource "aws_appautoscaling_target" "orders_read" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:ReadCapacityUnits"
  min_capacity       = 2000
  max_capacity       = 6000
}

resource "aws_appautoscaling_policy" "orders_read" {
  name               = "orders-read-target-tracking"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders_read.service_namespace
  resource_id        = aws_appautoscaling_target.orders_read.resource_id
  scalable_dimension = aws_appautoscaling_target.orders_read.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBReadCapacityUtilization"
    }
    target_value = 70
  }
}
resource "aws_dynamodb_table" "orders" {
  name           = "orders"
  billing_mode   = "PROVISIONED"
  read_capacity  = 2858
  write_capacity = 715
  hash_key       = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }

  lifecycle {
    ignore_changes = [read_capacity, write_capacity]
  }
}

resource "aws_appautoscaling_target" "orders_write" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:WriteCapacityUnits"
  min_capacity       = 500
  max_capacity       = 2000
}

resource "aws_appautoscaling_policy" "orders_write" {
  name               = "orders-write-target-tracking"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders_write.service_namespace
  resource_id        = aws_appautoscaling_target.orders_write.resource_id
  scalable_dimension = aws_appautoscaling_target.orders_write.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBWriteCapacityUtilization"
    }
    target_value = 70
  }
}

resource "aws_appautoscaling_target" "orders_read" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:ReadCapacityUnits"
  min_capacity       = 2000
  max_capacity       = 6000
}

resource "aws_appautoscaling_policy" "orders_read" {
  name               = "orders-read-target-tracking"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders_read.service_namespace
  resource_id        = aws_appautoscaling_target.orders_read.resource_id
  scalable_dimension = aws_appautoscaling_target.orders_read.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBReadCapacityUtilization"
    }
    target_value = 70
  }
}

The two aws_appautoscaling_target and aws_appautoscaling_policy pairs keep the Amazon DynamoDB table near 70% utilization for writes and reads. Global secondary indexes need their own pairs, with a resource_id of table/<table-name>/index/<index-name> and the dynamodb:index:ReadCapacityUnits and dynamodb:index:WriteCapacityUnits dimensions.

The lifecycle block stops Terraform from resetting read_capacity and write_capacity after auto scaling changes them. The Terraform AWS provider documentation recommends this whenever an auto scaling policy is attached to the table.

Switching billing_mode is an in-place update. Terraform changes the existing DynamoDB table without replacing it or touching its data.

Step-by-Step Fix Instructions

  1. Search Terraform configuration for every aws_dynamodb_table resource with billing_mode = "PAY_PER_REQUEST".

  2. For each DynamoDB table, pull at least 14 days of ConsumedReadCapacityUnits and ConsumedWriteCapacityUnits from Amazon CloudWatch, including any global secondary indexes.

  3. Compare average consumption with peak consumption. Tables with a flat or gently cyclical pattern are candidates; tables that sit idle and then spike are not.

  4. Size read_capacity and write_capacity so average consumption lands near your auto scaling target, such as 70%.

  5. Set billing_mode = "PROVISIONED", add the capacity values and the lifecycle block, and add aws_appautoscaling_target and aws_appautoscaling_policy resources for reads and writes.

  6. Run terraform plan to confirm the change is an in-place update, then apply during a period of normal traffic.

  7. Watch ReadThrottleEvents and WriteThrottleEvents in CloudWatch for the first few days, and raise minimum capacity if throttling appears.

  8. Add Infracost to CI/CD so new aws_dynamodb_table resources on on-demand capacity are flagged in the pull request for review.

Best Practices

  • Always pair provisioned capacity with auto scaling. A fixed read_capacity or write_capacity value either throttles at peak or wastes money off-peak.

  • Set auto scaling minimums at or above normal off-peak consumption. Auto scaling reacts in minutes, not seconds, so sudden jumps from a very low floor can throttle.

  • Keep new tables on on-demand capacity until they have a few weeks of traffic history. Move them to provisioned capacity once the pattern is clear.

  • Expose billing_mode as a variable in shared Terraform modules, so teams choose a DynamoDB capacity mode per table rather than inheriting a default.

  • Evaluate DynamoDB reserved capacity for tables with a stable provisioned baseline, since reserved capacity only applies to provisioned throughput.

Tools and Scripts

How does Infracost detect DynamoDB tables on on-demand capacity automatically?

The Amazon DynamoDB provisioned capacity policy is available in Infracost, including in the free trial. When Infracost runs in CI/CD, it checks aws_dynamodb_table resources added or changed in a pull request and flags tables configured with billing_mode = "PAY_PER_REQUEST" directly in the PR comment.

This gives the engineer a prompt to confirm the capacity mode while the change is still open, instead of discovering the premium on next month's bill. Infracost also scans existing repositories for DynamoDB tables already in violation, so FinOps and platform teams can burn down the backlog and measure progress as the count of non-compliant tables falls.

To list the capacity mode of every DynamoDB table in the current region:

for t in $(aws dynamodb list-tables --query "TableNames[]" --output text); do
  echo "$t $(aws dynamodb describe-table --table-name "$t" \
    --query "Table.BillingModeSummary.BillingMode" --output text)"
done
for t in $(aws dynamodb list-tables --query "TableNames[]" --output text); do
  echo "$t $(aws dynamodb describe-table --table-name "$t" \
    --query "Table.BillingModeSummary.BillingMode" --output text)"
done
for t in $(aws dynamodb list-tables --query "TableNames[]" --output text); do
  echo "$t $(aws dynamodb describe-table --table-name "$t" \
    --query "Table.BillingModeSummary.BillingMode" --output text)"
done

Tables created in provisioned mode that have never been switched may return None, which means PROVISIONED.

Examples of Impact

Illustrative example: a mature order service. An order service launched on on-demand capacity because traffic was unknown. Two years later, its DynamoDB table serves a steady daily curve that never drops below 40% of peak. Moving the table to provisioned capacity with auto scaling cuts its throughput bill roughly in half, with no code change.

Illustrative example: a nightly batch table. A reporting table receives heavy writes for one hour each night and almost nothing for the other 23 hours. This DynamoDB table stays on on-demand capacity, because provisioned capacity would bill for idle units most of the day.

This policy rewards steady traffic. It penalizes tables that are idle most of the time.

(These are illustrative, composite scenarios, not specific customer accounts.)

Considerations and Caveats

  • Throttling risk. Provisioned DynamoDB tables throttle requests above provisioned capacity once burst capacity runs out, while on-demand tables absorb sudden spikes more readily.

  • Switching limits. AWS allows a switch from provisioned to on-demand capacity up to four times in a 24-hour rolling window, and from on-demand to provisioned at any time.

  • Indexes count too. Each global secondary index on a provisioned DynamoDB table has its own read and write capacity, which also needs sizing and auto scaling.

  • Non-production is different. Development and test tables usually sit idle, so on-demand capacity is often cheaper there.

  • Reserved capacity is a commitment. DynamoDB reserved capacity cannot be cancelled or moved between Regions, so buy it only for a proven baseline.

Related Policies and Concepts

  • DynamoDB - consider using on-demand tables rather than provisioned in non-production projects: the companion Amazon DynamoDB capacity policy that recommends the opposite choice for idle development and test tables.

  • Amazon DynamoDB - consider using TTL on tables: a related policy on the same aws_dynamodb_table resource that targets storage cost rather than throughput cost.

  • BigQuery - consider using on-demand pricing before purchasing reserved slots: the same commit-only-after-usage-data principle applied to Google BigQuery capacity.

  • Azure SQL - consider using serverless with auto-pause in non-production: a parallel database policy that matches the billing model to how often the database is actually used.

Frequently Asked Questions (FAQs)

Is this policy supported in Infracost?

Yes. The Amazon DynamoDB provisioned capacity policy is supported in Infracost and available in the free trial. Infracost flags aws_dynamodb_table resources that use on-demand capacity in pull requests, so the team can confirm the billing mode before merge.

Can this policy be customized?

Yes. Teams can adjust how the Amazon DynamoDB provisioned capacity policy is enforced in Infracost, for example whether a finding only warns the engineer or blocks the pull request. Whether a specific table's traffic is steady enough for provisioned capacity remains a team decision based on CloudWatch data.

Does Infracost automatically fix violations?

No. Infracost identifies and reports aws_dynamodb_table resources that use on-demand capacity. Changing billing_mode, sizing read_capacity and write_capacity, and adding auto scaling are changes the team makes and reviews itself.

Is this policy cloud-agnostic?

No. The Amazon DynamoDB provisioned capacity policy targets Amazon DynamoDB through the aws_dynamodb_table Terraform resource. Other databases, such as Azure Cosmos DB, offer their own provisioned and serverless throughput models with different resource and attribute names.

When is DynamoDB on-demand cheaper than provisioned capacity?

DynamoDB on-demand capacity is cheaper for spiky, unpredictable, or mostly idle tables. AWS documentation states that on-demand mode costs less for workloads with average provisioned capacity utilization below 35%.

How often can I switch a DynamoDB table between capacity modes?

AWS allows a DynamoDB table to switch from provisioned to on-demand capacity up to four times in a 24-hour rolling window. A DynamoDB table can switch from on-demand to provisioned capacity at any time.

How often should I review DynamoDB capacity mode?

Teams running Infracost in CI/CD get a DynamoDB capacity mode check on every pull request that adds or changes an aws_dynamodb_table resource. For existing tables, a quarterly review of consumed capacity in CloudWatch is a reasonable cadence, since traffic patterns change as products mature.

Create Free Account

This policy is supported in Infracost and available in the free trial. Sign up today and scan your code using our entire library of FinOps policies.

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies