Beginner9 min read

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:

presentabsentQuery / ScanProcess ItemsLastEvaluatedKey?Set ExclusiveStartKeyDone

Every pass either resumes from the returned key or stops. There is no counter.

Run the pagination loop
1 KB
off

A full page at 1 KB per item — naive (1 MB ÷ item size): 1,048 items · actual (measured 19-byte per-item overhead): 1,029 items.

Position in the table0 of 10,000 items

No requests issued yet. Every press of “Fetch next page” is one Query call.

0 requests · 0 RCU accumulated

RequestItemsPage bytesLastEvaluatedKeyRead units
11,0291,029,000returned126
21,0291,029,000returned126
31,0291,029,000returned126
41,0291,029,000returned126
51,0291,029,000returned126
61,0291,029,000returned126
71,0291,029,000returned126
81,0291,029,000returned126
91,0291,029,000returned126
10739739,000absent90.5

The full run: 10 requests to walk all 10,000 items.

The page model is the one measured on this site’s limits reference: the 1 MiB page budget is spent on item bytes plus about 19 bytes of per-item overhead. Read units are eventually consistent — 0.5 RCU per 4 KB of aggregate page data. See the measurements on the DynamoDB limits reference.

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 expectWhat DynamoDB actually does
Limit: 25 → 25 rows in the pageEvaluates up to 25 items; filters may shrink the returned set
Short page → end of dataShort page + non-empty key → keep paging
Empty page → doneEmpty page + non-empty key → keep paging; there may be none
Limit controls bill per requestBill 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 :one in the same TransactWriteItems as each insert, and ADD itemCount :minusOne in the same transaction as each delete. Condition the insert on attribute_not_exists (and the delete on attribute_exists) and pass a ClientRequestToken, so a retry can't count twice (aggregate on write).
  • Rough whole-table count. DescribeTable returns ItemCount, 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

ApproachBest forTrade-off
Manual do/while on the keyFull control, custom backoff, mixed opsEasy to forget error handling or capacity caps
SDK paginator (paginateQuery)Batch jobs, exports, CLI toolsLess control over per-page side effects
Signed or encrypted nextTokenMobile/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.

By Dmitriy · Updated