Beginner7 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}));
  process(out.Items);
  key = out.LastEvaluatedKey;
} while (key);

When LastEvaluatedKey is undefined, you've reached the end. 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 response cap. 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 still returns a LastEvaluatedKey if more data remains. 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 — you must keep paging until the key is empty, even when a page looks short. A non-empty LastEvaluatedKey never promises more matching items either; 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 → more data exists beyond the filter
Limit controls bill per requestBill follows items read, including filtered-out rows

A concrete read: partition USER#42 holds 200 order items averaging 2 KB each. Query with Limit: 50 and FilterExpression: status = 'OPEN' might evaluate 50 items (~100 KB metered), match 4, and return a key — you page again. Without the filter, the same Limit: 50 evaluates 50 items and bills ~12.5 read capacity units on-demand (50 × 2 KB → 100 KB, rounded up per 4 KB block at 0.5 RCU each for eventually-consistent reads). Pass ReturnConsumedCapacity: TOTAL on every call to see the metered units per page instead of guessing.

Let the SDK paginate

Both SDKs wrap the loop above so you can iterate pages directly:

// AWS SDK for JavaScript v3
import {paginateQuery} from '@aws-sdk/lib-dynamodb';
for await (const page of paginateQuery({client}, params)) {
  process(page.Items);
}
# boto3
paginator = client.get_paginator('query')
for page in paginator.paginate(**params):
    process(page['Items'])

No page numbers

DynamoDB has no total count and no random page access — you can't jump to "page 7" or page backwards without replaying the cursors. Design UIs around infinite scroll / "load more", not numbered pages. (A Select: 'COUNT' query still reads — and bills for — every matched item to count them.)

Stateless cursors for APIs

LastEvaluatedKey is just the key attributes of the last item. Base64-encode it and hand it to clients as an opaque nextToken; decode it back into ExclusiveStartKey on the next request. No server-side cursor state.

That token is DynamoDB-JSON — eyeball or hand-craft one with the DynamoDB-JSON converter. And if you're paging to work around a Scan, that's usually a signal to add an index instead.

Treat the token as opaque and immutable. Clients must send back exactly what you issued; decoding, mutating a sort-key component, and re-encoding 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. For Scan pages, the key includes the segment id when you use parallel segments — a token from segment 2 must resume segment 2, not segment 0.

PartiQL's ExecuteStatement uses the same resume model under a different name: NextToken on the response becomes NextToken on the next request. The mental model — loop until the token is absent — is identical to 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
Base64 nextToken in your APIMobile/web "load more"Must validate and never expose raw table keys
DynoTable result gridExploratory reads, verifying key orderClient-side; not a server pagination pattern

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 touch 1 MB of item data meter roughly ten times the single-page cost. Background jobs that walk an entire table via Query on a GSI should multiply "cost per page" by "pages until key absent" before scheduling — the pricing calculator accepts that per-page unit count directly.

Large responses also hit wire limits before capacity limits: if a single item approaches 400 KB, you may get one item per page regardless of Limit. The item size calculator shows when an item crosses the 4 KB read rounding boundary (one RCU per 4 KB for strongly-consistent reads, half that eventually consistent).

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 the underlying API fields and consistency options, see Querying in DynoTable — the same pagination rules apply whether you call the SDK, PartiQL, or the desktop app's PartiQL tab.

Try DynoTable to page through query results visually, with the cursor tracked for you and ReturnConsumedCapacity surfaced per request so you can see each page's read units without wrapping every call yourself.

Updated