DynamoDB Pagination: LastEvaluatedKey Explained
DynamoDB never returns "all" results in one call. A Query or Scan returns at
most 1 MB of data, then hands you a LastEvaluatedKey to resume from. Getting
pagination right means looping on that key, not on a counter.
How does pagination work in DynamoDB?
A Query or Scan returns at most 1 MB per call, then hands back a LastEvaluatedKey. To page, you pass that key as the next call's ExclusiveStartKey and loop until DynamoDB returns no key. There are no page numbers, no total count, and Limit caps items evaluated, not items returned.
let key;
do {
const out = await client.send(new QueryCommand({...params, ExclusiveStartKey: key}));
handle(out.Items);
key = out.LastEvaluatedKey;
} while (key);When LastEvaluatedKey is undefined, you've reached the end. Otherwise pass it
back as ExclusiveStartKey to fetch the next slice.
Each page is bounded by two independent ceilings: the Limit you set (if any)
and a hard 1 MB cap on the data read. A partition with chunky items can fill a
page on three rows even when Limit is 100.
DynamoDB stops when either bound is hit and returns a LastEvaluatedKey. Plan UI
copy around "load more" rather than "showing 25 of N", because N is unknown until
you've walked every page.
The control flow is a single loop that exits only on an absent key:
Every pass either resumes from the returned key or stops. There is no counter.
Limit is not a page size
Limit caps how many items DynamoDB evaluates, not how many it returns after a
FilterExpression. A Limit: 25 query behind a filter can return 3 items and still
hand you a LastEvaluatedKey. Keep paging until the key is absent, even when a page
looks short.
A non-empty LastEvaluatedKey doesn't promise more matching items either. AWS:
"If LastEvaluatedKey is not empty, it does not necessarily mean that there is more
data in the result set." Only an absent key proves you've reached the end.
| What you might expect | What DynamoDB actually does |
|---|---|
Limit: 25 → 25 rows in the page | Evaluates up to 25 items; filters may shrink the returned set |
| Short page → end of data | Short page + non-empty key → keep paging |
| Empty page → done | Empty page + non-empty key → keep paging; there may be none |
Limit controls bill per request | Bill follows items read, including filtered-out rows |
A concrete read: partition USER#42 holds 200 items averaging 2 KB each. A Query
with Limit: 50 and FilterExpression: status = 'OPEN' might evaluate 50 items, match
4, and return a key, so you page again.
With or without the filter, that page bills ~12.5 read units eventually consistent:
50 × 2 KB is 100 KB, which is 25 blocks of 4 KB at 0.5 each. Pass
ReturnConsumedCapacity: TOTAL on every call to see the metered units per page.
When empty pages pile up, the filter is doing a key's job. Filtering strategies shows how to move the condition into a sort key or a sparse index.
Let the SDK paginate
Both SDKs wrap the loop above so you can iterate pages directly:
// AWS SDK for JavaScript v3, low-level client
import {DynamoDBClient, paginateQuery} from '@aws-sdk/client-dynamodb';
const client = new DynamoDBClient({});
for await (const page of paginateQuery({client}, params)) {
handle(page.Items);
}The paginateQuery in @aws-sdk/lib-dynamodb is the same loop for a
DynamoDBDocumentClient and plain JavaScript values; it rejects the low-level
client.
# boto3
paginator = client.get_paginator('query')
for page in paginator.paginate(**params):
handle(page['Items'])No page numbers
DynamoDB has no total count and no random page access. You can't jump to "page 7", and DynamoDB keeps no page history. Design UIs around infinite scroll or "load more", and add the rest yourself:
- Previous page. Keep a stack of the
ExclusiveStartKeys you issued, client-side or in the token. Page n−1 is the stack entry before the current one. - Total count. Keep a counter item and bump it with
UpdateItem ... ADD itemCount :onein the sameTransactWriteItemsas each insert, andADD itemCount :minusOnein the same transaction as each delete. Condition the insert onattribute_not_exists(and the delete onattribute_exists) and pass aClientRequestToken, so a retry can't count twice (aggregate on write). - Rough whole-table count.
DescribeTablereturnsItemCount, which DynamoDB updates about every six hours (table size and item count).
A Select: 'COUNT' query is not a shortcut. It reads and bills every item it
evaluates, and counts one page at a time: you still loop on LastEvaluatedKey and sum
Count.
Stateless cursors for APIs
LastEvaluatedKey holds the key attributes of the last item evaluated (which a
filter may have dropped), plus the index keys when you query an index. Hand it to
clients as an opaque nextToken and turn it back into ExclusiveStartKey on the
next request. No server-side cursor state.
Base64 alone isn't opaque: anyone can decode it and read your table's keys. Encrypt
the token (AES-GCM with a server-held key, or AWS KMS Encrypt), or at least
HMAC-sign it and reject any token whose signature doesn't match.
Treat the token as immutable. Clients must send back exactly what you issued; editing a sort-key component breaks the resume point and can skip or duplicate rows.
Version the envelope ({"v":1,"lek":…}) so you can rotate encoding without breaking
in-flight sessions.
A parallel Scan key does not record its segment. Store Segment and TotalSegments in the envelope next to it: resuming segment 2's
key on segment 0 is rejected with a ValidationException: the exclusive start key
has to fall inside the segment being scanned (parallel scans).
To inspect a raw LastEvaluatedKey while debugging, the
DynamoDB-JSON converter turns it into plain JSON.
And if you're paging to work around a Scan, that's
usually a signal to add an index instead.
PartiQL's ExecuteStatement uses the same resume model under a different name:
NextToken on the response becomes NextToken on the next request. Loop until the
token is absent, as with Query/Scan.
Pick a pagination strategy
| Approach | Best for | Trade-off |
|---|---|---|
Manual do/while on the key | Full control, custom backoff, mixed ops | Easy to forget error handling or capacity caps |
SDK paginator (paginateQuery) | Batch jobs, exports, CLI tools | Less control over per-page side effects |
Signed or encrypted nextToken | Mobile/web "load more" | You own signing keys and validation |
Whichever path you choose, never infer progress from page index.
Page 14 of a Scan over a growing table is not "14 × Limit items in": items added or
deleted between calls can shift boundaries.
Idempotent downstream writes (natural keys, conditional puts) keep replays safe when a client retries the same token after a timeout.
Capacity adds up across pages
Pagination does not discount reads. Ten pages that each read 1 MB of item data meter roughly ten times the single-page cost.
Before you schedule a job that walks a whole table or index, multiply "cost per page" by "pages until the key is absent", then turn that into reads per second for the pricing calculator.
Large items hit the 1 MB cap before Limit: if items approach 400 KB, you get only
two or three per page regardless of Limit.
Query and Scan add up the size of every item on the page and round the total up
to 4 KB; per-item rounding is the GetItem/BatchGetItem rule. The
item size calculator shows what one item
weighs.
Build and inspect the loop
To skip writing the loop at all, the query builder composes the full Query/Scan request and emits a runnable SDK v3, CLI, or boto3 program, pagination loop included.
Set your partition key, optional sort condition, projection, and filter; the emitted
program wraps paginateQuery or an equivalent manual loop with ExclusiveStartKey
wiring already in place.
For how DynoTable pages a query (Load more, Run to end), see Querying in DynoTable. For the API fields, the AWS Query reference is the source.
Try DynoTable to page through query results visually, with the cursor tracked for you: Load more fetches the next page, and Run to end reads until the key is absent.