Skip to main content
emnode
Cost

Delete unused DynamoDB tables

A DynamoDB table with no reads keeps billing for storage, reserved capacity, backups, and replicas, find the dead ones, archive their data, and delete them before the layers compound.

13 min·10 sections·AWS

Last reviewed

Unused DynamoDB tables: the basics

Why a table nobody queries is rarely free

DynamoDB bills on several independent meters, and 'nobody is using it' only silences one of them. Storage is always charged at roughly $0.25 per GB-month for whatever items the table holds, whether or not a single request ever touches them. If the table is in provisioned-capacity mode, you also keep paying for the reserved RCU/WCU you set (around $0.00013 per provisioned RCU-hour and $0.00065 per WCU-hour) regardless of whether any read or write actually happens. On-demand mode drops the capacity charge but never the storage charge.

The check flags tables with effectively zero ConsumedReadCapacityUnits and a flat ReturnedItemCount over roughly 30 days, a table that exists, stores bytes, and serves nobody. These are almost always old feature-flag stores, abandoned prototypes, the staging half of a migration that finished a year ago, or a table whose only consumer was a Lambda that has since been deleted. A 40 GB provisioned table left at 100 RCU / 100 WCU is quietly about $10 of storage plus $57 of reserved capacity every month for doing nothing.

It's flagged because the waste hides in layers. The base table cost is bad enough, but teams routinely turn on Point-in-Time Recovery (another ~$0.20/GB-month of continuous backups), add a Global Tables replica in a second region (a full duplicate of storage and write capacity), or front the table with a DAX cluster (an always-on node fleet), all of which keep billing on a table whose request graph has been flat at zero for a month.

In this lesson you'll learn the multi-meter billing model that makes an idle DynamoDB table surprisingly expensive, how to tell a genuinely dead table from a rarely-but-legitimately-read one (audit and compliance tables get written constantly and read only during an incident), and the safe path (export the data to S3, then delete) that reclaims the spend without losing anything you might need. You'll see the CLI to inspect a table and pull its CloudWatch request metrics, the reference-hunting you must do via CloudTrail and IAM before deleting, and the Global Tables and PITR edge cases that turn a one-line delete into a multi-region cleanup if you skip them, including that a Global Tables replica you forget to remove keeps billing on its own in the other region.

Fun fact

The flag table that outlived its flag

A platform team at a logistics company found a DynamoDB table named prod-feature-flags-v2 storing 11 GB, in provisioned mode at 200 RCU / 200 WCU, with Point-in-Time Recovery enabled and a Global Tables replica in eu-west-1. Its CloudWatch graph showed zero ConsumedReadCapacityUnits for the full retention window. The feature-flag system it served had been replaced by a SaaS tool eighteen months earlier; the only thing still writing to it was a cron Lambda nobody had turned off. Between provisioned capacity in two regions, storage, and continuous backups, the dead table was billing about $310 a month, for a flag set the product had stopped reading a year and a half before.

Deleting an unused DynamoDB table in action

Nina runs the FinOps cadence at a mid-sized SaaS company. The dashboard flags 9 DynamoDB tables with no read activity over 30 days, totalling about $420 of monthly wastage. The largest is staging-orders-migration, 28 GB in provisioned mode at 150 RCU / 150 WCU, with PITR enabled, roughly $7 storage, $85 reserved capacity, $6 continuous backups a month, all on a table whose request graph is flat at zero.

She pulls the table description and the CloudWatch metrics first. ConsumedReadCapacityUnits is zero for the whole window and ReturnedItemCount never moves: nothing is reading it. But before touching it she greps CloudTrail and IAM for references: no Lambda has it in an environment variable, no IAM policy grants access except one stale role attached to a function that was deleted in 2025. PITR being on makes her pause (that's a 'real data once lived here' signal) so she doesn't delete blind.

Nina exports the table to S3 first (DynamoDB's native export, about $0.10/GB one-off, preserving every item as JSON outside the table cost model), confirms the export completes, then deletes the table, which also tears down PITR automatically. Total reclaimed: about $98 a month for this one table; the S3 archive sits at a few dollars a year in case the migration data is ever needed for an audit.

First, inspect the table's billing mode and provisioned capacity, then pull 30 days of read activity from CloudWatch to confirm nothing is reading it.

$ aws dynamodb describe-table --table-name staging-orders-migration --query 'Table.{Mode:BillingModeSummary.BillingMode,RCU:ProvisionedThroughput.ReadCapacityUnits,WCU:ProvisionedThroughput.WriteCapacityUnits,SizeGB:TableSizeBytes,Items:ItemCount}' && aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB --metric-name ConsumedReadCapacityUnits --dimensions Name=TableName,Value=staging-orders-migration --start-time 2026-04-26T00:00:00Z --end-time 2026-05-26T00:00:00Z --period 86400 --statistics Sum --query 'Datapoints[].Sum'
{
"Mode": "PROVISIONED",
"RCU": 150,
"WCU": 150,
"SizeGB": 30064771072,
"Items": 4182334
}
[]
# 150 RCU / 150 WCU reserved, ~28 GB stored, and zero consumed reads for 30 days.

Provisioned capacity bills whether or not it's used; an empty Datapoints array means nothing read this table all month.

Before deleting, export the table's data to S3 (native point-in-time export, a one-off ~$0.10/GB that preserves every item outside the table cost model), then delete the table.

$ aws dynamodb export-table-to-point-in-time --table-arn arn:aws:dynamodb:us-east-1:123456789012:table/staging-orders-migration --s3-bucket finops-cold-archive --s3-prefix dynamodb-exports/staging-orders-migration --export-format DYNAMODB_JSON && aws dynamodb delete-table --table-name staging-orders-migration
{
"ExportDescription": {
"ExportArn": "arn:aws:dynamodb:...:export/01716...",
"ExportStatus": "IN_PROGRESS",
"S3Bucket": "finops-cold-archive"
}
}
{
"TableDescription": {
"TableStatus": "DELETING"
}
}
# Export to S3 first, confirm it completes, then delete; deleting the table also tears down PITR.

Export-then-delete: the S3 archive costs a few dollars a year at ~$0.023/GB-month versus the table's $0.25/GB-month plus capacity.

DynamoDB billing under the hooddeep dive

DynamoDB has no concept of 'off.' A table either exists or it's deleted, and as long as it exists its stored data bills at roughly $0.25 per GB-month in US-East regardless of request volume. On top of storage, the capacity meter depends on the billing mode. In PROVISIONED mode you pay for the RCU/WCU you reserve (about $0.00013 per RCU-hour and $0.00065 per WCU-hour) so 150 RCU plus 150 WCU is roughly $85 a month before a single request arrives. In PAY_PER_REQUEST (on-demand) mode the capacity charge disappears when traffic is zero, but storage never does; an on-demand table with 100 GB of forgotten items is still $25 a month.

Add-ons stack on independent meters. Point-in-Time Recovery enables continuous backups at roughly $0.20 per GB-month, and its mere presence is a forensic signal that someone once considered the data important. Global Tables (current v2, 2019.11.21) replicates the whole table to another region: a full second copy of storage plus replicated write units (rWCU), so a dead table with one replica costs roughly double. In v2 every replica is an equal peer with no privileged source: deleting one region's replica only removes that region's copy and stops replication to it; the remaining replica simply converts to a standalone single-region table and keeps billing. So the cleanup task is to remove each replica deliberately, region by region, or you leave a forgotten copy billing in a region nobody is watching. DAX, the in-memory accelerator, runs a dedicated node fleet billed per node-hour whether or not the cache is warm, so an idle DAX cluster fronting a dead table is pure overhead.

The two metrics that decide whether a table is actually dead are ConsumedReadCapacityUnits and ReturnedItemCount from the AWS/DynamoDB CloudWatch namespace, summed over 30 days. Zero consumed reads and a flat returned-item count means nothing is reading it. The trap is the rarely-read table: audit logs, compliance archives, and break-glass stores get written steadily but read only during an incident, so always cross-check ConsumedWriteCapacityUnits and hunt for references in CloudTrail and IAM before concluding a written-but-unread table is safe to delete.

# Find any IAM identity that still references the table before deleting.
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=staging-orders-migration \
  --max-results 10 \
  --query 'Events[].[EventTime,EventName,Username]' \
  --output table

# Check both PITR (real-data signal) and Global Tables replicas (each region bills separately) before any delete.
aws dynamodb describe-continuous-backups \
  --table-name staging-orders-migration \
  --query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus'

aws dynamodb describe-table \
  --table-name staging-orders-migration \
  --query 'Table.Replicas[].RegionName'

What is the impact of unused DynamoDB tables?

The direct cost is the layered bill on a table nobody reads. Storage alone at $0.25/GB-month makes a 100 GB forgotten table $25 a month before anything else. In provisioned mode, reserved capacity dominates: 150 RCU / 150 WCU is roughly $85 a month whether or not a request ever lands. Add PITR (~$0.20/GB-month of continuous backups), a Global Tables replica (a full duplicate of storage and write capacity in a second region), and an idle DAX node, and a single dead table can clear $300 a month. Across a portfolio of micro-services with a graveyard of old tables, this reaches $5-20k a month of pure waste.

There's a sneaky second-order cost: provisioned-capacity mode doesn't self-correct. An on-demand table at least stops charging for capacity when traffic dies, but a provisioned table keeps billing its reserved RCU/WCU forever, the very mode teams choose for 'predictable' workloads is the one that quietly keeps paying after the workload disappears. Auto-scaling can lower the floor, but only if it was configured, and a forgotten table rarely was.

Global Tables make the cleanup a multi-region job, not a one-line delete. In the current v2 model every replica is an equal peer: deleting one region's replica removes only that region's copy and stops replication to it; it does not cascade the delete to the other regions. That's actually the trap in reverse: delete one region and walk away, and the table lives on as a standalone single-region copy still billing in the other region, possibly still read by another team's application there. So you have to remove each replica deliberately, region by region, until none is left, not assume one delete tears the whole thing down. PITR has its own gotcha: it's the strongest signal that the table once held real business data, so a table with PITR on is exactly the one to export and review rather than delete on a whim.

Finally, dead tables make incident response and audit harder. A year from now someone needs the order history a migration left behind, finds a candidate table attached to no running service, and has no idea whether it's authoritative or a stale copy. Old data sitting in unowned tables is a security and compliance liability as much as a cost one. A short list of live tables is far easier to reason about (and defend in an audit) than a long graveyard of maybes.

How do you delete unused DynamoDB tables safely?

Cleanup is a four-step loop that runs at every FinOps cadence: inventory what has no reads, hunt for hidden references, export-then-delete (remove every Global Tables replica region by region first), and tag the survivors so this doesn't repeat.

1. Inventory every table with no reads, by age and owner

Pull every table across every region and account into one sheet with billing mode, provisioned RCU/WCU, table size, PITR status, Global Tables replicas, the Owner tag, and 30-day ConsumedReadCapacityUnits. Zero consumed reads over 30 days is a candidate. Critically, also pull ConsumedWriteCapacityUnits, a table that's written but never read may be an audit or compliance store that's read only during incidents, so flag those for human review rather than deletion.

2. Hunt for references before doing anything destructive

Zero reads in CloudWatch doesn't prove zero dependents: a consumer might be broken, scheduled, or about to deploy. Grep CloudTrail for any access events in the window, and check IAM policies, Lambda environment variables, and ECS task definitions for the table name or ARN. A PITR-enabled table is a 'real data once lived here' signal, so treat it as guilty until proven safe. Never delete a table whose only evidence of disuse is a flat read graph.

3. Export to S3, remove replicas, then delete

Run DynamoDB's native export-to-S3 (point-in-time export, ~$0.10/GB one-off) to preserve every item as JSON in cheap object storage at ~$0.023/GB-month, far below the table's $0.25/GB-month plus capacity. The export requires Point-in-Time Recovery to be enabled on the table: without it the call fails with PointInTimeRecoveryUnavailableException, so a non-PITR table must have PITR turned on first (which itself starts a backup charge until you delete the table). Wait for the export to complete. If the table is part of a Global Tables relationship, remove each replica region one by one; in the current v2 model a replica you leave behind simply becomes a standalone single-region table that keeps billing in that region, so the goal is to clear every region, not to rely on one delete cascading. Then delete the remaining table; that automatically tears down PITR and provisioned capacity.

4. Tag new tables with intent so the next audit is shorter

Adopt a two-tag convention: Lifecycle=ephemeral|persistent and Owner=<person>, enforced with a tag policy or AWS Config rule. Default new tables to on-demand billing so an abandoned one stops charging for capacity automatically, and reserve provisioned mode for workloads with proven steady traffic. A scheduled audit can then flag any ephemeral table with 30 days of zero reads, and the next month's report shrinks. The loop only stays manageable if new tables arrive pre-labelled.

# Export to S3 (requires PITR enabled), wait for completion, remove replicas, then delete.
TABLE=staging-orders-migration
ARN=$(aws dynamodb describe-table --table-name $TABLE --query 'Table.TableArn' --output text)

# Export needs PITR on, or it fails with PointInTimeRecoveryUnavailableException.
aws dynamodb update-continuous-backups --table-name $TABLE \
  --point-in-time-recovery-specification PointInTimeRecoveryEnabled=True

aws dynamodb export-table-to-point-in-time \
  --table-arn $ARN \
  --s3-bucket finops-cold-archive \
  --s3-prefix dynamodb-exports/$TABLE \
  --export-format DYNAMODB_JSON

# Remove each Global Tables replica region; a skipped one keeps billing standalone.
for region in eu-west-1; do
  aws dynamodb update-table --table-name $TABLE \
    --replica-updates "[{\"Delete\":{\"RegionName\":\"$region\"}}]"
done

# Only after the export is confirmed complete and replicas are gone.
aws dynamodb delete-table --table-name $TABLE

Quick quiz

Question 1 of 5

You find a 50 GB DynamoDB table with zero consumed reads for 30 days, in provisioned mode at 150 RCU / 150 WCU, with Point-in-Time Recovery enabled and a Global Tables replica in eu-west-1. What's the right next move?

You've completed Delete unused DynamoDB tables. You now know why an idle table is rarely free (storage, reserved capacity, backups, and replicas each on their own meter) how to tell a dead table from a rarely-read audit store, and the safe export-then-delete loop (references checked, replicas before source) that reclaims spend without losing data. The next time the wastage report flags a table with no reads, you'll have a defensible path from 'flagged' to 'resolved' without risking a multi-region incident.

Back to the library