Cost Engineering
AWS
The Frugal Approach to AWS DynamoDB Costs
Craig Conboy

With usage-billed database services like DynamoDB, cost optimization starts with your code. It's not just that applications read and write items; applications spend. Every GetItem, every Scan, every oversized item contributes to your bill—and unlike a relational database hiding waste inside a fixed instance price, DynamoDB meters nearly everything per request.

That's actually good news. Per-request billing means code-level waste shows up directly on the bill, which means fixing the code shows up directly too. Here's a practical walk-through of what to look for and how to optimize DynamoDB costs at the code and table-design level.

Cost Trap Efficiency Pattern
Uncached repeated reads Cache hot items in the application or DAX
Item-at-a-time reads and writes Use BatchGetItem / BatchWriteItem
Scan-and-filter access patterns Query on keys; design keys for your access patterns
Fetching full items for a few attributes Use ProjectionExpression
Strongly consistent reads by default Default to eventually consistent; opt in where it matters
Oversized items Keep items lean; blobs belong in S3
GSIs projecting everything Project only the attributes the index serves
Expired data that never leaves Enable TTL instead of paying to store and delete
Transactions where plain writes do Reserve TransactWriteItems for genuine invariants
Wrong capacity mode for the workload Match on-demand vs provisioned to traffic shape

Attribute the Costs

Start with your bill. Break down costs by table, index, and application to understand where spend concentrates. DynamoDB charges across three main dimensions: writes (~$0.625 per million on-demand write request units), reads (~$0.125 per million read request units), and storage (~$0.25/GB-month). The units are size-based: one WRU covers a write up to 1 KB, one RRU covers an eventually consistent read up to 4 KB—strongly consistent reads cost double, transactional operations double again.

Those multipliers are the map to your bill. A read-heavy table spending big on RRUs points at over-fetching, missing caches, or Scans; write costs that dwarf the item count point at oversized items or GSI fan-out; storage that only grows points at data with no TTL. CloudWatch's ConsumedReadCapacityUnits/ConsumedWriteCapacityUnits per table and per index tell you where the units go; your code tells you why. This granular attribution reveals which of the 10 efficiency patterns below deliver the highest impact.


Tackling Read Costs

Cache hot reads.

Batch the round-trips.

Query, don't Scan.

Fetch attributes, not items.

Stop paying double for consistency you don't need.

Cache hot items

Cost impact: Read units

The same handful of items—configuration, reference data, the current user's profile—read on every request, at full price, forever:

# Bad: read config on every request
def handle(event):
    config = table.get_item(Key={'pk': 'CONFIG'})['Item']
    ...

# Good: cache with a TTL
def handle(event):
    config = cache.get('CONFIG')
    if config is None:
        config = table.get_item(Key={'pk': 'CONFIG'})['Item']
        cache.set('CONFIG', config, ttl=300)
    ...

For read-heavy tables where microseconds matter, DAX gives you the same effect as a managed write-through cache.

Benefit: 30-70% reduction in read units for cache-friendly access patterns.

Batch reads and writes

Cost impact: Request overhead and latency

The units cost the same batched or not—but a loop of single calls pays network round-trip and request overhead per item, inflates Lambda duration while it waits, and hits throttling limits sooner:

# Bad: 100 sequential round-trips
items = [table.get_item(Key={'pk': k})['Item'] for k in keys]

# Good: one call per 100 keys
resp = dynamodb.batch_get_item(
    RequestItems={'my-table': {'Keys': [{'pk': k} for k in keys]}}
)
items = resp['Responses']['my-table']

BatchGetItem takes up to 100 keys, BatchWriteItem up to 25 puts/deletes. Handle UnprocessedKeys/UnprocessedItems—partial success is part of the contract.

Benefit: Up to 100x fewer round-trips; less compute time spent waiting on I/O.

Query on keys, don't Scan and filter

Cost impact: Read units (the big one)

Scan reads the entire table and bills every byte it touches—FilterExpression filters after the read is already paid for. A 10 GB table costs ~320,000 RRUs per full Scan regardless of how many items match:

# Bad: pay to read 10 GB, keep 200 rows
resp = table.scan(
    FilterExpression=Attr('status').eq('PENDING')
)

# Good: pay to read 200 rows
resp = table.query(
    IndexName='status-index',
    KeyConditionExpression=Key('status').eq('PENDING')
)

A recurring Scan in a request path or cron job is almost always a missing key design or missing GSI. Design keys around access patterns first; Scan is for migrations and one-offs.

Benefit: Read cost drops from table-size to result-size—commonly 99%+ on affected paths.

Project only the attributes you need

Cost impact: Read units and egress

RRUs are billed on item size read. Reading 20 KB items to use three fields pays 5 RRUs where 1 would do:

# Bad: full 20 KB item for two attributes
item = table.get_item(Key={'pk': user_id})['Item']
return item['name'], item['tier']

# Good: fetch the attributes, not the item
item = table.get_item(
    Key={'pk': user_id},
    ProjectionExpression='#n, tier',
    ExpressionAttributeNames={'#n': 'name'}
)['Item']

Benefit: Read units proportional to what you use, not what you stored.

Default to eventually consistent reads

Cost impact: Read units (2x)

ConsistentRead=True doubles the read cost. Most read paths—rendering a profile, listing history, anything a human looks at—tolerate replication lag measured in milliseconds. Reserve strong consistency for read-your-own-write flows where a stale read breaks correctness, and let everything else ride the default.

Benefit: Halves read cost on every path that opts back down.


Tackling Write & Storage Costs

Keep items small.

Trim index projections.

Let TTL take out the trash.

Don't pay transaction prices for ordinary writes.

Keep items lean

Cost impact: Write units, read units, and storage

WRUs bill per 1 KB, rounded up. A 9 KB item costs 9 WRUs per write and 3 RRUs per strongly consistent read—size taxes every operation, forever. The classic offenders: JSON blobs, base64 payloads, and denormalized history stuffed into the item:

# Bad: 300 KB report inside the item (300 WRUs per write)
table.put_item(Item={'pk': rid, 'report': huge_json})

# Good: item holds the pointer, S3 holds the payload (1 WRU)
s3.put_object(Bucket='reports', Key=rid, Body=huge_json)
table.put_item(Item={'pk': rid, 'report_s3': f's3://reports/{rid}'})

Benefit: Write and read units drop proportionally with item size; storage moves to S3 at ~a tenth the per-GB price.

Project only what each GSI serves

Cost impact: Write units and storage (multiplied)

Every GSI is a shadow copy: each write to the base table replays into every index that includes the item, billed separately, and ProjectionType: ALL stores a full duplicate. Three ALL-projection GSIs turn one 4 KB write into four and quadruple storage:

# Bad: the index serves a lookup but copies everything
GlobalSecondaryIndexes=[{
    'IndexName': 'by-email',
    'Projection': {'ProjectionType': 'ALL'}
}]

# Good: the index carries what its queries read
GlobalSecondaryIndexes=[{
    'IndexName': 'by-email',
    'Projection': {'ProjectionType': 'INCLUDE',
                   'NonKeyAttributes': ['name', 'tier']}
}]

Audit GSIs against the queries that actually hit them; delete the ones nothing queries at all.

Benefit: Write amplification and index storage cut to what the access pattern requires.

Enable TTL on expiring data

Cost impact: Storage and write units

Sessions, tokens, events with a shelf life—without TTL they accumulate at $0.25/GB-month until a cleanup job pays write units to delete them. DynamoDB's TTL deletes expired items for free:

table.put_item(Item={
    'pk': session_id,
    'data': payload,
    'expires_at': int(time.time()) + 86400   # TTL attribute, epoch seconds
})

One table setting, one numeric attribute, no deletion sweep to build or bill.

Benefit: Storage stops growing unboundedly; deletion becomes free.

Reserve transactions for real invariants

Cost impact: Write and read units (2x)

TransactWriteItems and TransactGetItems cost double per item. They exist for multi-item invariants—debit one account, credit another. Wrapping independent single-item writes in a transaction, or using one where a ConditionExpression on a single item would do, pays 2x for atomicity nothing needed.

Benefit: Halves the cost of every operation that drops back to plain writes.


Tackling Capacity Mode Costs

Match capacity mode to traffic shape

Cost impact: The pricing model itself

On-demand charges per request; provisioned charges per hour of reserved throughput. Steady, predictable traffic on on-demand overpays (per-request pricing runs roughly 4-7x the equivalent well-utilized provisioned throughput); spiky or idle-most-of-the-day tables on provisioned pay for capacity that mostly waits—or throttle when the spike exceeds it.

The heuristic: sustained utilization above ~30-40% of what you'd provision favors provisioned (with auto-scaling); bursty, unpredictable, or near-zero baselines favor on-demand. Check ConsumedReadCapacityUnits against the billed mode per table—dev and staging tables on provisioned capacity, idling at 1%, are the most common finding and the easiest fix.

Benefit: Often 50-80% off the throughput bill for tables on the wrong mode.


Closing Thoughts

Cost-effective DynamoDB requires two steps. First, let observed costs guide what needs optimization. Attribute your bill down to tables, indexes, and the access patterns behind them—which tables burn read units on Scans and full-item fetches, which writes fan out into ALL-projection GSIs, which storage never expires, which capacity mode fights the traffic shape. This granular attribution reveals which of the 10 efficiency patterns above matter most for your workloads. Second, apply the optimizations that address your cost concentrations: cache and batch reads, Query instead of Scan (99%+ on affected paths), project attributes instead of items, drop unneeded strong consistency and transactions (2x each), keep items lean, trim GSI projections, enable TTL, and put each table on the capacity mode its traffic deserves.

You don't lose performance or reliability—most of these make the application faster. DynamoDB bills you for exactly what you ask of it; the discipline is asking for only what you need.

Looking for help with cost optimizations like these? Book a Demo or Explore the Sandbox to see Frugal in action. Frugal attributes AWS DynamoDB costs to your code, finds inefficient usage, provides Frugal Fixes that reduce your bill, and helps keep you out of cost traps for new and changing code.

Back to Top
background Image
Robo Image

Take Frugal for a live test drive

Explore how Frugal scans your code and cloud services to find waste, recommend optimizations, and generate ready-to-use fixes in a secure, read-only environment.
background Image
robo image