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:
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 — 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 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 → more data exists beyond the filter |
Limit controls bill per request |
Bill 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 ~25 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
| 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 |
Base64 nextToken in your API |
Mobile/web "load more" | Must validate and never expose raw table keys |
| DynoTable result grid | Exploratory reads, verifying key order | Client-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.

Top comments (0)