Why Your DynamoDB Query Stops Early: Limit, LastEvaluatedKey, and the 1 MB Rule
DynamoDB pages Query results by the 1 MB read cap, not your Limit. Here's how LastEvaluatedKey actually works, why FilterExpression can return empty pages, and a loop that doesn't drop records.
26 Sept 2026, 00:54 UTC

You set Limit: 100 on a DynamoDB Query, got back 23 items, and assumed the table only had 23 matches. It probably doesn't. DynamoDB pages results by data size, not by your limit, and the only reliable signal that more data exists is a field called LastEvaluatedKey. Misreading that contract is one of the most common ways applications silently drop records.
The 1 MB read cap decides your page size
Every Query or Scan call returns at most 1 MB of data. That's a hard ceiling, evaluated before your Limit even matters in some cases. If your items average 50 KB, a page holds roughly 20 of them no matter what limit you set. If items are tiny, you might get exactly your Limit count. Either way, the page boundary is DynamoDB's decision, not yours.
The response tells you where it stopped via LastEvaluatedKey — the primary key of the last item evaluated. If that field is present, more data exists. If it's absent, you're done. The correct loop condition is "keep going while LastEvaluatedKey exists," never "keep going while I got fewer items than my limit."
Limit counts items evaluated, not items returned
Here's the subtler trap: Limit controls the number of items DynamoDB evaluates, and a FilterExpression is applied after the read. So with Limit: 50 and a filter that matches 2 items, a page may contain zero items while LastEvaluatedKey is still present.
Two consequences follow:
- You must paginate through empty pages.
- You consume read capacity for every item evaluated, including those filtered out.
If you find yourself filtering out most of what you read, the access pattern belongs in the key design — using a sort key avoids scanning irrelevant data.
A working pagination loop
The pattern below uses the AWS SDK for JavaScript v3 (@aws-sdk/lib-dynamodb). Run it in a Node.js environment with IAM permissions for dynamodb:Query on the table.
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
async function fetchAllOrders(docClient, customerId) {
const results = [];
let lastKey = undefined;
do {
const resp = await docClient.send(new QueryCommand({
TableName: "Orders",
KeyConditionExpression: "customerId = :c",
ExpressionAttributeValues: { ":c": customerId },
Limit: 100,
ExclusiveStartKey: lastKey
}));
results.push(...(resp.Items ?? []));
lastKey = resp.LastEvaluatedKey;
} while (lastKey);
return results;
}
ExclusiveStartKeyis exactly what the previous response returned. Treat it as opaque data — never construct it manually.- The loop is a
do...whileso the first call goes out without a start key; passingundefinedsimply omits the parameter. - Each iteration is a separate request consuming read capacity.
Most AWS SDKs also ship built-in paginators that run this loop for you. They still issue one request per 1 MB page under the hood, and the exact API differs by SDK language and version, so confirm against your SDK's current docs.
Trade-offs worth knowing before you ship
Pagination is sequential by nature: each page depends on the previous page's key, so you cannot parallelize a single Query's pages. Deep pagination also means latency grows linearly with result size. If a workflow routinely walks thousands of items, the better fix is a global secondary index or an analytical export.
Also note that paginating a live table gives no snapshot isolation — items written or deleted between page requests can shift what you see. For anything requiring strict consistency, use ConsistentRead and understand it is still not a point-in-time snapshot across pages.
Verify it yourself
Create a test table, insert enough items to exceed 1 MB total (a few hundred items with 5 KB each), then:
- Run a Query with
Limit: 10and confirmLastEvaluatedKeyappears in the response. - Pass that key back as
ExclusiveStartKeyand confirm the next page resumes immediately after the last item — no duplicates, no gaps. - Add a
FilterExpressionthat matches few items and observe pages with zero returned items but a non-nullLastEvaluatedKey. - Set
ReturnConsumedCapacity: "TOTAL"and check the reportedConsumedCapacityto see that filtered-out items still cost reads.
The actionable takeaway: audit every Query and Scan call in your codebase for how it decides to stop. If any loop ends when Items.length < Limit, or ignores LastEvaluatedKey entirely, it is dropping data today. Replace it with the loop above or your SDK's paginator, then test against a table whose data exceeds 1 MB — that is the only test that exercises the boundary your production traffic will eventually hit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.