The workload below is made up and every figure is illustrative. Prices are AWS public on-demand list prices for US East (N. Virginia), checked October 13, 2026. Sources are at the end.

In the post on archiving cold DynamoDB data to S3 I said that table size is a poor estimate of what you pay, partly because global secondary indexes (GSIs) are billed storage too. This post goes one level deeper. A GSI is not a pointer structure that comes free with the table. It is a separate table that DynamoDB keeps in sync for you, and you pay for its storage and for every write that keeps it in sync.

That’s fine when the index earns its keep. In practice, many tables have indexes that were created with the console defaults on day one and never looked at again. Below is how to find them, what to change, and a checklist you can run yourself in an afternoon.

How a GSI actually costs money

Every time you write an item to the base table, DynamoDB checks each GSI. If the item has the index key attributes, the index gets its own write. You pay for that write in write request units (WRUs) on-demand, or write capacity units (WCUs) when provisioned, sized by the projected item, in 1 KB increments.

flowchart LR
    A[App writes 1.5 KB item] --> B[(Base table<br/>2 WRU)]
    B --> C[(GSI by-customer<br/>ALL: 2 WRU)]
    B --> D[(GSI by-status<br/>ALL: 2 WRU)]
    B --> E[(GSI by-type<br/>ALL: 2 WRU)]
    style B fill:#e8f0fe

One application write became four billed writes. Three things make it worse:

  • Key changes cost double. If an update changes an index key attribute, DynamoDB deletes the old index entry and writes a new one. That’s two index writes for one base update.
  • Projection drives size. With ALL, the index entry is the whole item, so it rounds up exactly as the base write does. With KEYS_ONLY, the entry is the table key plus the index key, usually well under 1 KB, so it costs 1 WRU.
  • Storage stacks the same way. Each index stores its projected entries plus about 100 bytes of overhead per item. Three ALL indexes on a table that every item reaches is roughly four copies of your data.

Projection types, in plain terms

Projection What the index stores Write cost per item Storage When it fits
ALL Every attribute Same as base write ~Full copy Hot query path that needs the whole item and can’t afford a second read
INCLUDE Keys + named attributes Size of keys + those attributes Partial You need a few fields, like a list view
KEYS_ONLY Table key + index key Usually 1 WRU Small You only need to find items, then fetch the few you need

The tradeoff with KEYS_ONLY is a follow-up read. A query on the index returns keys, and you call BatchGetItem for the items you actually show. Reads are cheap: on-demand reads are $0.125 per million read request units, against $0.625 per million write units. If an index is written far more often than it’s queried, which is the normal case for event and log style tables, trading writes for reads usually wins.

One catch: you can’t change a GSI’s projection in place. You create a new index with the projection you want, move readers over, then delete the old one. The new index backfills from the base table, and that backfill is billed as writes. I’ll include it in the numbers below.

Sparse indexes: index only what you query

A GSI only gets an entry when the item has the index’s key attributes. That’s the most underused cost lever in DynamoDB. If the only query you run on a status index is “show me events that still need review”, don’t index every status. Write a needs_review attribute only on items that need review, and remove it when they’re done. The index then holds only the live working set.

flowchart TB
    W[Write event] --> Q{Has needs_review?}
    Q -- "yes (~5%)" --> S[(Sparse GSI<br/>entry written)]
    Q -- "no (~95%)" --> N[No index write,<br/>no index storage]

Removing the attribute deletes the index entry, which is one more index write. That’s still far cheaper than indexing everything forever.

Finding unused indexes

DynamoDB publishes per-index CloudWatch metrics with the dimensions TableName and GlobalSecondaryIndexName. An index nobody reads still shows write consumption, but its consumed read capacity sits at zero.

flowchart LR
    L[List GSIs] --> R[30-day Sum of<br/>ConsumedReadCapacityUnits]
    R -->|0| U[Unused: confirm<br/>with code search, drop]
    R -->|low| P[Rarely used: KEYS_ONLY<br/>or sparse candidate]
    R -->|high| K[Keep. Check<br/>projection size]

Check at least 30 days, and longer if you have month-end or quarter-end jobs. An index read once a quarter by a reconciliation job looks dead in a two-week window. Before dropping anything, search your code and infrastructure definitions for the index name. Zero reads in the metrics plus zero references in code is a strong signal; either one alone isn’t.

A worked example

Here is a made-up app-events table, on-demand capacity, Standard table class:

  • About 1.43 billion items averaging 1.5 KB, so about 2,048 GB of base data.
  • 200 million writes a month. At 1.5 KB, each is 2 WRUs.
  • Three GSIs, all created with ALL projection: by-customer, by-type, and by-status.
  • Every item has all three index keys, and assume each write touches each index once.

After the audit: by-customer and by-type are rebuilt as KEYS_ONLY. Their entries are about 150 bytes, so 1 WRU per write and roughly 10% of base storage each. by-status is replaced with a sparse needs-review index that keeps ALL projection but only covers the ~5% of items under review. The two KEYS_ONLY indexes now need follow-up reads. Say 50 million items a month are fetched by key with eventually consistent reads, at 0.5 RRU each.

Single Region

Line item Before After
Base table writes 400M WRU → $250.00 400M WRU → $250.00
GSI writes 3 × 400M = 1,200M WRU → $750.00 200M + 200M + 20M = 420M WRU → $262.50
Storage 2,048 × 4 = 8,192 GB → $2,048.00 2,048 + 205 + 205 + 102 = 2,560 GB → $640.00
Extra follow-up reads — 25M RRU → $3.13
Monthly total $3,048.00 $1,155.63

That’s about $1,892 a month saved, a 62% cut, without touching the base table.

With a global table in three Regions

Global tables bill a full replica in every Region. That covers storage, the replicated writes to each replica, and the index writes in each Region. Replicated write request units are also $0.625 per million in US East, so for a rough estimate you can multiply the per-Region cost by three. The follow-up reads stay in the one Region that serves them.

  Before After
Three Regions 3 × $3,048.00 = $9,144.00 3 × $1,152.50 + $3.13 = $3,460.63

That’s about $5,683 a month saved. Other Regions have slightly different prices, so treat the multiplier as an estimate and check your Cost and Usage Report for the real figures.

The one-time cost of rebuilding

New indexes backfill from the base table, and that backfill is billed:

  • Each KEYS_ONLY index: 1.43B items × 1 WRU ≈ $894.
  • Sparse index: about 71.5M items × 2 WRU ≈ $89.
  • Per Region, that’s about $1,877. Across three Regions, about $5,632.

Against $5,683 a month in savings, the rebuild pays for itself in about one month. As with the archival backfill, a new index’s backfill competes for write throughput, so build one index at a time and watch for throttling on the base table.

gantt
    title Rebuild order (one index at a time)
    dateFormat YYYY-MM-DD
    section by-customer
    Create KEYS_ONLY + backfill :a1, 2026-11-02, 2d
    Move readers, drop old       :a2, after a1, 3d
    section by-type
    Create KEYS_ONLY + backfill :b1, after a2, 2d
    Move readers, drop old       :b2, after b1, 3d
    section by-status
    Add needs_review writes      :c0, after b2, 2d
    Create sparse index          :c1, after c0, 1d
    Move readers, drop old       :c2, after c1, 3d

For the sparse index, start writing the needs_review attribute before you create the index, and backfill it onto existing items under review. Otherwise the new index starts out empty and your review queue looks clean when it isn’t.

The audit checklist

Run these against each table that shows up near the top of your DynamoDB bill. Replace app-events with your table name.

1. List indexes with their projection, size, and item count.

aws dynamodb describe-table --table-name app-events \
  --query 'Table.{base_bytes:TableSizeBytes,base_items:ItemCount,
    gsis:GlobalSecondaryIndexes[].{name:IndexName,
      projection:Projection.ProjectionType,
      included:Projection.NonKeyAttributes,
      bytes:IndexSizeBytes,items:ItemCount}}'

Size and item count update about every six hours, so they’re fine for an audit. Flag any index whose size is close to the base table’s, and any ALL projection.

2. Check whether the table is a global table.

aws dynamodb describe-table --table-name app-events \
  --query 'Table.Replicas[].RegionName'

Multiply everything you find by the number of Regions listed, plus the one you’re in if it isn’t listed.

3. Pull 30 days of reads and writes per index.

for idx in by-customer by-type by-status; do
  for m in ConsumedReadCapacityUnits ConsumedWriteCapacityUnits; do
    echo -n "$idx $m: "
    aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB \
      --metric-name $m \
      --dimensions Name=TableName,Value=app-events \
                   Name=GlobalSecondaryIndexName,Value=$idx \
      --start-time $(date -u -d '-30 days' +%FT%TZ) \
      --end-time $(date -u +%FT%TZ) \
      --period 2592000 --statistics Sum \
      --query 'Datapoints[0].Sum' --output text
  done
done

On macOS, swap the date -d call for date -u -v-30d +%FT%TZ.

4. Compare reads to writes. For each index:

  • Reads are zero: probably unused. Confirm with a code search, then drop it.
  • Writes are much higher than reads: a candidate for KEYS_ONLY or INCLUDE.
  • Most index queries filter on one value: a candidate for a sparse index.
  • Reads are high and need the full item: keep ALL, and you’re done with that index.

5. Search code and infrastructure for each index name. That means application code, scheduled jobs, analytics exports, and your infrastructure-as-code. Note who reads it and which attributes they use. That list is your INCLUDE projection if you need one.

6. Estimate before and after. Use the formulas above: writes × WRU per entry × $0.625 per million, and index GB × $0.25 per Region. Add the one-time backfill and the extra follow-up reads. If payback is longer than a few months, it’s probably not worth the change risk.

7. Rebuild one index at a time. Create the new index, wait for IndexStatus to reach ACTIVE, switch readers behind a flag, watch errors and latency, then delete the old index:

aws dynamodb update-table --table-name app-events \
  --attribute-definitions AttributeName=customer_id,AttributeType=S \
                          AttributeName=created_at,AttributeType=S \
  --global-secondary-index-updates '[{"Create":{
      "IndexName":"by-customer-v2",
      "KeySchema":[{"AttributeName":"customer_id","KeyType":"HASH"},
                   {"AttributeName":"created_at","KeyType":"RANGE"}],
      "Projection":{"ProjectionType":"KEYS_ONLY"}}}]'

# later, after readers have moved:
aws dynamodb update-table --table-name app-events \
  --global-secondary-index-updates '[{"Delete":{"IndexName":"by-customer"}}]'

On a provisioned table, the Create block also needs ProvisionedThroughput. On a global table, run index changes in one Region and they apply to all replicas.

8. Verify on the bill. After a full billing cycle, compare write units and storage for the table in the Cost and Usage Report. If the numbers didn’t move the way the estimate said, find out why before touching the next table.

What not to cut

Not every ALL index is waste. If a latency-sensitive path reads the full item from the index thousands of times a second, a follow-up BatchGetItem adds a round trip and read cost that can outweigh the savings. INCLUDE with just the fields that path shows is often the middle ground. Also remember that removing a projected attribute breaks any reader that quietly depended on it, which is why step 5 comes before step 7.

Summary

  • A GSI is a table you pay to keep in sync. Count its writes and storage as you would a table’s.
  • ALL is the expensive default. Most write-heavy, lightly queried indexes do fine with KEYS_ONLY plus a follow-up read.
  • Sparse indexes let you index the working set instead of the history.
  • Zero reads over 30+ days, plus no references in code, means drop it.
  • Global tables multiply all of it by the number of Regions.

If the base table itself is the problem, the step-by-step method for moving cold DynamoDB data to S3 picks up from here, and it shrinks every index along with the table.

Happy to take a look if you want a second pair of eyes; here’s how to ask for a review.


Price sources (US East, N. Virginia, checked October 13, 2026):