Where the money goes
| Component | Driven by |
|---|---|
| Writes | Item size (1 KB write units) × writes, × every GSI the item projects into |
| Reads | Item size (4 KB read units) × reads; eventually consistent reads cost half |
| Storage | Table + index size |
| Extras | Backups, global table replication, streams, exports |
Check Cost Explorer by usage type for each table (tags help) to know which applies.
1. On-demand or provisioned?
| Traffic | Mode |
|---|---|
| Unpredictable, spiky, new tables, low volume | On-demand |
| Steady and predictable, high volume | Provisioned + auto scaling (+ reserved capacity for the baseline) |
Compare: last month's consumed RCU/WCU per hour (CloudWatch ConsumedReadCapacityUnits/ConsumedWriteCapacityUnits) priced as provisioned at ~70% target utilisation vs actual on-demand cost. Switching modes is limited to a few times per 24 hours, so test on one table first.
aws dynamodb update-table --table-name orders --billing-mode PROVISIONED \
--provisioned-throughput ReadCapacityUnits=200,WriteCapacityUnits=100
aws application-autoscaling register-scalable-target --service-namespace dynamodb \
--resource-id table/orders --scalable-dimension dynamodb:table:WriteCapacityUnits --min-capacity 50 --max-capacity 1000
Add a target-tracking policy (~70%) for each dimension and for each GSI.
2. Table class: Standard-IA for storage-heavy tables
Standard-Infrequent Access lowers storage price and raises read/write price. It pays off when storage is the dominant cost (old orders, history, audit data that's rarely read):
aws dynamodb update-table --table-name order-history --table-class STANDARD_INFREQUENT_ACCESS
3. Expire data with TTL
aws dynamodb update-time-to-live --table-name sessions \
--time-to-live-specification "Enabled=true, AttributeName=expiresAt"
expiresAt is a Unix epoch seconds number. TTL deletes are free (no write units consumed) and happen in the background, typically within a few days of expiry, so filter expired items in queries if exactness matters. Stream the deletions to S3 if you need an archive.
4. Index design
- Every GSI adds write cost for every item write that touches projected attributes. Remove unused GSIs (check
ConsumedReadCapacityUnitson the index). - Use
KEYS_ONLYorINCLUDEprojections instead ofALLwhen queries need few attributes. - Sparse indexes: only items with the index key attribute are indexed. Useful for "open orders" style queries.
5. Item size and access patterns
- Large items cost more on every read/write. Move big blobs to S3 and store a pointer.
- Shorter attribute names reduce size on very large tables.
ProjectionExpressionreduces network transfer but not read units (those are based on the full item size). If rarely used large attributes inflate every read, split them into a separate item.- Batch and query instead of scans; scans read (and bill) the whole table.
6. Cache hot reads
DAX or an application cache (ElastiCache) for read-heavy, repeated keys: product catalogues, configuration, session lookups. Check consistency needs; DAX serves eventually consistent reads.
7. Other line items
- Point-in-time recovery and on-demand backups: keep, but expire old on-demand backups.
- Global tables: replicated writes cost in every region. Only replicate tables that need multi-region.
- Streams consumers: filter events in Lambda event source mappings.
Tip
Look at the write cost per table first. It's often multiplied by GSIs nobody remembers creating. Dropping one unused GSI with an
ALLprojection can cut a table's write bill substantially.
Checklist
- Cost split by reads, writes, storage, extras per table
- Capacity mode chosen from consumed capacity data
- Standard-IA for storage-dominated tables
- TTL on expiring data
- Unused GSIs removed; projections trimmed
- Large attributes moved to S3; scans eliminated
- Caching for hot keys