This content originally appeared on Level Up Coding - Medium and was authored by Mayank Jain
And I found out the hard way — while my Lambda was silently burning money in the background

There’s a specific kind of pain that comes from opening your AWS Cost Explorer on a Monday morning and seeing a number you didn’t expect.
That was me, about eight months into building a reporting pipeline at work. We had Lambda functions generating detailed analytics reports — order summaries, inventory snapshots, revenue breakdowns — for restaurant chains with hundreds of outlets. The kind of reports that need to pull transaction data across months, join it with product data, apply outlet-level filters, and spit out a formatted document.
The Lambda worked. Reports were generating. Customers were happy.
Then we looked at the DynamoDB bill.
It wasn’t catastrophic. But it was three times what it should have been for the volume we were handling. And the worst part? The code looked completely fine. No obvious red flags. No missing indexes. Just a few scan operations and some filtering logic that seemed harmless when I wrote it.
It wasn’t harmless.
The Silent Problem With DynamoDB Billing
Before I get into what went wrong, you need to understand how DynamoDB actually charges you — because it’s not how most databases work.
In a traditional SQL database, a query that returns 10 rows and scans 10,000 rows costs “one query.” Expensive in compute, sure, but the billing is flat.
DynamoDB charges you for Read Capacity Units (RCUs) — and an RCU is consumed for every 4KB of data read, not every row returned.
Let that sink in.
If your query scans 500KB of data but only returns 20KB of relevant records, you’ve consumed 125 RCUs. Not 5. Not 20. 125.
Every Scan operation in DynamoDB reads your entire table (or partition) and then filters the result after reading it. The filtering happens in memory, on AWS's side. You pay for all the data that was read, including the rows you threw away.
This is the thing that catches almost every developer who comes from a relational background. The query looks fast, the result looks correct, the code looks clean. The billing looks insane.
What Our Reporting Lambda Was Actually Doing
Here’s a simplified version of what our pipeline looked like. We had a Lambda function triggered on-demand (and on a schedule for nightly reports) that needed to pull transaction records for a given outlet, filter by date range, and aggregate them.
The original code looked something like this:
const result = await dynamodb.scan({
TableName: 'transactions',
FilterExpression: 'outletId = :oid AND #ts BETWEEN :start AND :end',
ExpressionAttributeValues: {
':oid': outletId,
':start': startDate,
':end': endDate
},
ExpressionAttributeNames: { '#ts': 'timestamp' }
}).promise();Looks reasonable. Returns the right data. Ships to production.
What it’s actually doing: reading every single transaction in the table, for every outlet, across all time, and then filtering down to just the ones we need. For a restaurant chain with years of transaction history, that’s millions of rows being scanned every time someone generates a report.
For a nightly report job that ran across 50 outlets, this was doing 50 full table scans. Every. Single. Night.
The Lambda timeout wasn’t hit. The memory wasn’t maxed out. The reports were correct. But DynamoDB was reading gigabytes of data and charging us for every 4KB of it.
The Fix Is Not Complicated — But You Have to Know It Exists
The difference between a Scan and a Query in DynamoDB is enormous — and not just in performance.
A Query operation requires a partition key. It goes directly to that partition and reads only the data within it. A Scan reads everything first, then filters.
The fix starts with your table design.
If you’re designing a table where you’ll regularly need to fetch records by outletId + date range, your partition key should be outletId and your sort key should be timestamp. Then your query becomes:
const result = await dynamodb.query({
TableName: 'transactions',
KeyConditionExpression: 'outletId = :oid AND #ts BETWEEN :start AND :end',
ExpressionAttributeValues: {
':oid': outletId,
':start': startDate,
':end': endDate
},
ExpressionAttributeNames: { '#ts': 'timestamp' }
}).promise();The code change is almost identical. The billing change is not.
Instead of reading the entire table, DynamoDB goes directly to the outletId partition and reads only the records within the date range. For 90 days of transaction data on a single outlet, you might be reading 2–3MB instead of 400MB. That's a 100–200x reduction in RCU consumption, not an exaggeration.
For our nightly report job across 50 outlets, that was the difference between ~40,000 RCUs per run and ~300.
But What If You Query on a Non-Key Attribute?
This is where most people get stuck. “Great, Query is better — but I need to filter on fields that aren’t my primary key.”
That’s what Global Secondary Indexes (GSIs) are for.
Say you have a transactions table with transactionId as the partition key (because that's what makes each record unique). But your reporting Lambda needs to query by outletId + timestamp. You can't Query on those fields without making them the primary key — which might break other access patterns.
The answer: create a GSI where outletId is the partition key and timestamp is the sort key.
// Creating the GSI (done at table creation or via UpdateTable)
// GSI name: 'outlet-timestamp-index'
// Partition key: outletId
// Sort key: timestamp
const result = await dynamodb.query({
TableName: 'transactions',
IndexName: 'outlet-timestamp-index',
KeyConditionExpression: 'outletId = :oid AND #ts BETWEEN :start AND :end',
ExpressionAttributeValues: {
':oid': outletId,
':start': startDate,
':end': endDate
},
ExpressionAttributeNames: { '#ts': 'timestamp' }
}).promise();Same query logic, correct access pattern, no full table scan.
GSIs cost money too — writes to your base table are replicated to the GSI. But for a read-heavy reporting workload, the write overhead is almost always cheaper than the alternative of scanning millions of rows on every read.
The Projection Problem Nobody Talks About
Here’s a secondary issue that showed up in our pipeline and I’ve almost never seen covered in DynamoDB tutorials.
When you query DynamoDB — even with a proper Query operation — you get back entire items by default. If your transaction record has 30 attributes (item name, category, tax breakdowns, modifiers, timestamps, audit fields…) but your report only needs 4 of them, you’re still paying to read all 30.
This is the ProjectionExpression.
const result = await dynamodb.query({
TableName: 'transactions',
IndexName: 'outlet-timestamp-index',
KeyConditionExpression: 'outletId = :oid AND #ts BETWEEN :start AND :end',
ProjectionExpression: 'transactionId, #ts, totalAmount, itemCount',
ExpressionAttributeValues: {
':oid': outletId,
':start': startDate,
':end': endDate
},
ExpressionAttributeNames: { '#ts': 'timestamp' }
}).promise();Now DynamoDB returns only the 4 fields you need. The item being read is smaller. The RCU consumption drops in proportion to how much you’ve trimmed.
For our reporting Lambda, where we were pulling 10,000+ transactions to aggregate into summary numbers, this alone cut read costs by about 60%.
Pagination: The Silent Cost Multiplier
One more thing that burned us, and it’s specific to Lambdas doing large data pulls.
DynamoDB doesn’t return all results in one call when a dataset is large. It paginates — returning a LastEvaluatedKey when there are more results to fetch. Most tutorials show a basic example where you just keep looping until LastEvaluatedKey is undefined.
The problem is that if you’re not careful, every page request is a separate read operation — and if you’re running this in a Lambda that’s pulling large date ranges, you can easily hit dozens or hundreds of pages. Each page is billed independently.
async function getAllTransactions(outletId, startDate, endDate) {
let items = [];
let lastKey = undefined; do {
const params = {
TableName: 'transactions',
IndexName: 'outlet-timestamp-index',
KeyConditionExpression: 'outletId = :oid AND #ts BETWEEN :start AND :end',
ProjectionExpression: 'transactionId, #ts, totalAmount, itemCount',
ExpressionAttributeValues: {
':oid': outletId,
':start': startDate,
':end': endDate
},
ExpressionAttributeNames: { '#ts': 'timestamp' },
ExclusiveStartKey: lastKey,
Limit: 1000 // Fetch in larger chunks to reduce round trips
};const result = await dynamodb.query(params).promise();
items = items.concat(result.Items);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
return items;
}
Setting a reasonable Limit (we used 1000) reduces the number of round trips and Lambda execution time. For very large date ranges, consider whether you actually need all records in memory — aggregating as you paginate is almost always better than loading everything at once.
A Real Numbers Comparison
Before and after the fix on our reporting pipeline (approximate, for a single outlet generating a monthly report):
Operation Data Scanned RCUs Consumed Approx. Cost Scan with filter ~400 MB ~100,000 ~$0.13 per report Query on GSI with projection ~3 MB ~750 ~$0.001 per report
At 50 outlets, running nightly reports plus on-demand requests, the monthly difference was roughly $180 vs $14. Not a startup-killer, but entirely unnecessary — and it scales up fast as data grows.
More importantly, the Lambda execution time dropped from an average of 8–12 seconds to under 1 second per outlet. That’s not just cost — it’s user experience.
The Quick Checklist
If you have DynamoDB in your stack and haven’t audited this, here’s where to start:
Scan vs Query — search your codebase for dynamodb.scan. Every one of those is a full table read. Ask whether you could achieve the same result with a Query if the table had the right key structure.
Access patterns first — DynamoDB rewards you for thinking about how you’ll read data before you design the table. The primary key and GSIs should map directly to your most frequent query shapes.
ProjectionExpression — any Query or Scan that doesn’t need all fields should have one. Takes two minutes to add, reduces read costs proportionally.
Large range queries in Lambdas — if you’re pulling unbounded date ranges or large datasets inside a Lambda, add a Limit, paginate properly, and consider whether you can aggregate on the fly rather than loading everything into memory.
Check CloudWatch Metrics — ConsumedReadCapacityUnits in CloudWatch will show you exactly which tables and indexes are burning the most. Start there.
The Uncomfortable Truth
DynamoDB is genuinely excellent when used correctly. For the reporting workload I described — high read throughput, predictable access patterns, scale without managing servers — it’s hard to beat.
But it punishes you quietly when used wrong. There’s no query planner warning you that you’re doing a full table scan. There’s no slow query log flagging expensive operations. The code works, the data is correct, and the bill just keeps climbing.
The difference between a DynamoDB bill that makes sense and one that doesn’t is almost always access patterns and index design — not scale, not traffic, not bad luck.
Audit your Scans. Add your GSIs. Project only what you need.
Your Monday morning AWS bill will thank you.
If you’ve run into similar DynamoDB billing surprises — or found other patterns that silently inflate costs — I’d love to hear about it in the comments.
Your DynamoDB Queries Are 10x More Expensive Than They Need to Be was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Mayank Jain
Mayank Jain | Sciencx (2026-06-08T04:16:59+00:00) Your DynamoDB Queries Are 10x More Expensive Than They Need to Be. Retrieved from https://www.scien.cx/2026/06/08/your-dynamodb-queries-are-10x-more-expensive-than-they-need-to-be/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.