DynamoDB Pagination: LastEvaluatedKey and ExclusiveStartKey Examples in Python, JavaScript, Java and the CLI, Plus PartiQL, Page Tokens, Next.js, Empty Pages and What It Costs

Logeshwaran
—

DynamoDB pagination works with two fields: every Query or Scan response that stops early includes LastEvaluatedKey, and you pass that value back as ExclusiveStartKey to get the next page. Keep going until a response has no LastEvaluatedKey. A page ends at 1 MB of data read or at your Limit, whichever comes first, so pagination happens even when you never asked for it: a query over 2,500 orders of about 650 bytes each returned only 1,582 items in its first response. And a LastEvaluatedKey does not promise more data: 20 items read 10 at a time took three calls, and the third came back empty.

Jake's order-history page had a "Load more" button that Ethan built in an afternoon. It worked for months, until a regular customer complained that her history stopped in March and the button showed a blank page. Ethan read the code once and laughed, not happily. "It's not broken," he said. "It's doing exactly what DynamoDB does. I just never read the rules."

⚡ Quick Answer

• The loop: read a page, take LastEvaluatedKey, send it as ExclusiveStartKey, repeat until it is missing. How it works.

• Code: Python (boto3), JavaScript (SDK v3), Java (SDK 2.x), AWS CLI, PartiQL, Next.js.

• Java trap: in SDK 2.x, lastEvaluatedKey() is never null, so a != null loop never ends. Use hasLastEvaluatedKey(). Details.

• Small pages cost more: each request rounds up to 4 KB, so 120 small pages cost 17% more than 2 big ones. Cost per page.

• Pages end at 1 MB of data read, or at Limit items read, before any filter. Filters and short pages.

• The last page can be empty. Only a missing LastEvaluatedKey means done. Why.

• For an API: encode LastEvaluatedKey as a page token and check it on the way back in. Page tokens.

GSI and sort order · Page numbers and counts · Items that change between pages · Scan · Errors · Best practices · FAQ.

This guide shows how pagination in DynamoDB really works, with tested examples in Python, JavaScript and the AWS CLI, then the parts that catch people in production: empty pages, filters, page tokens for an API, page numbers and counts. If DynamoDB itself is new to you, what Amazon DynamoDB is explains tables, partition keys and sort keys first.

🧭 NEW HERE? READ THESE FIRST

New to DynamoDB? These pages explain the basics this guide uses:

💡 DynamoDB never returns "everything". It returns a page and a bookmark, and your code decides whether to ask for the next one.

How DynamoDB pagination works: the 1 MB page and LastEvaluatedKey

DynamoDB divides the results of a Query or Scan into pages, and a single request only ever returns one page. A page ends at whichever of these comes first:

What ends a pageDetails
1 MB of data readAlways applies, whether or not you set Limit. It counts the items read, before any filter is applied.
Limit items readOnly if you set Limit. Like the 1 MB rule, it counts items read, not items returned after a filter.
The end of the dataThe partition (for a query) or the table (for a scan) has nothing more to read.

When a page ends for either of the first two reasons, the response includes LastEvaluatedKey: the primary key of the last item DynamoDB read. It is a bookmark. You send it back as ExclusiveStartKey in the next request, with the same key condition, and DynamoDB starts reading just after that item ("exclusive" means the bookmarked item itself is not returned again). When the response has no LastEvaluatedKey at all, you have the final page.

Here is what the bookmark looks like for a table whose partition key is customer and sort key is order_id, the LastEvaluatedKey format people ask about. In boto3's resource interface it is a plain dictionary of the key attributes:

{'order_id': 'o00009', 'customer': 'jake'}

With the low-level client (and the raw API) the same key comes with type markers, like any DynamoDB attribute value: {'order_id': {'S': 'o00009'}, 'customer': {'S': 'jake'}}. Either way, it holds only key attributes, never your other data.

The 1 MB rule is the one that surprises people, because it applies even when you never set Limit. A query for one customer with 2,500 orders of about 650 bytes each, with no Limit, returned this:

items in first page: 1582
LastEvaluatedKey: {'order_id': 'o01581', 'customer': 'jake'}
ScannedCount: 1582

That is the bug in Jake's shop in one line. Ethan's first version called query once and showed the result; it worked while customers had a few hundred orders, and silently cut off the rest once one customer's history passed 1 MB.

Ethan, explaining it to Jake: "DynamoDB hands you one page and a bookmark. If you don't ask for the next page, it doesn't send it."

DynamoDB pagination in Python (boto3): the loop and the paginator

There are two ways to paginate in boto3, and both work. The loop is the most direct and works with both the resource and the client interfaces:

import boto3
from boto3.dynamodb.conditions import Key

table = boto3.resource("dynamodb").Table("orders")

def all_orders(customer):
    kwargs = {"KeyConditionExpression": Key("customer").eq(customer)}
    while True:
        page = table.query(**kwargs)
        yield from page["Items"]
        if "LastEvaluatedKey" not in page:
            break
        kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]

orders = list(all_orders("jake"))
print(len(orders))   # 2500, across two pages

Three details make this loop correct. It checks for the presence of LastEvaluatedKey, not the number of items, so a short or empty page does not stop it early. It keeps the same key condition on every call. And it is a generator, so a caller that only needs the first few items can stop without reading the whole partition.

The paginator does the same loop for you, and it can also cap the total number of items. It is available on the low-level client:

client = boto3.client("dynamodb")
paginator = client.get_paginator("query")

pages = paginator.paginate(
    TableName="orders",
    KeyConditionExpression="customer = :c",
    ExpressionAttributeValues={":c": {"S": "ethan"}},
    PaginationConfig={"PageSize": 5, "MaxItems": 7},
)
result = pages.build_full_result()
print(len(result["Items"]))      # 7
print(result.get("NextToken"))   # a token to resume from item 8

PageSize sets Limit on each underlying request, and MaxItems caps the total across pages. When MaxItems stops the paginator part-way through a page, it returns a NextToken; pass it back as PaginationConfig={"StartingToken": token, ...} and the next run starts at item 8. Note that this NextToken is boto3's own token, not DynamoDB's LastEvaluatedKey. Decoded, it holds the start key plus how many items of the current page to skip:

{"ExclusiveStartKey": {"order_id": {"S": "e04"}, "customer": {"S": "ethan"}},
 "boto_truncate_amount": 2}

So use NextToken only with the paginator, and LastEvaluatedKey only with ExclusiveStartKey; mixing them fails. To iterate pages one by one instead of building one big result, loop over paginator.paginate(...) directly and read page["Items"] from each.

Which to choose: the loop when you need control (an API that returns one page per request, a stop condition, retries), the paginator for scripts and jobs that read everything. Both cost exactly the same, because they send the same requests.

DynamoDB pagination in JavaScript (AWS SDK v3)

In JavaScript with the AWS SDK for JavaScript v3, the document client gives you plain JavaScript values, and the loop looks the same as in Python. This was run with SDK version 3.1156.0 on Node.js 24:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, paginateQuery } from "@aws-sdk/lib-dynamodb";

const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));

// 1. the loop: one request per page
let startKey, page = 0;
do {
  const out = await doc.send(new QueryCommand({
    TableName: "orders",
    KeyConditionExpression: "customer = :c",
    ExpressionAttributeValues: { ":c": "ethan" },
    Limit: 8,
    ExclusiveStartKey: startKey,
  }));
  page++;
  console.log(`page ${page}: ${out.Items.length} items`);
  startKey = out.LastEvaluatedKey;
} while (startKey);

// 2. the paginator: for await over pages
for await (const p of paginateQuery({ client: doc, pageSize: 8 }, {
  TableName: "orders",
  KeyConditionExpression: "customer = :c",
  ExpressionAttributeValues: { ":c": "ethan" },
})) {
  console.log(p.Items.length);
}

For a customer with 20 orders the loop prints three pages:

page 1: 8 items, LastEvaluatedKey={"order_id":"e07","customer":"ethan"}
page 2: 8 items, LastEvaluatedKey={"order_id":"e15","customer":"ethan"}
page 3: 4 items, LastEvaluatedKey=undefined
total 20

The paginator returned the same 20 items in 3 pages. Use the paginateQuery from @aws-sdk/lib-dynamodb when you use the document client, and the one from @aws-sdk/client-dynamodb with the plain client; they are separate functions. In SDK v3, pageSize sets Limit per request. The older SDK v2 DocumentClient follows the same loop pattern with ExclusiveStartKey.

DynamoDB pagination in Java (AWS SDK for Java 2.x) and the null trap

Java has the one pagination bug that does not stop on its own. In the AWS SDK for Java 2.x, QueryResponse.lastEvaluatedKey() never returns null. When there are no more pages, it returns an empty map. So the loop most people write first, while (lastKey != null), never ends: on the last page it receives {}, sends it back as the start key, and the SDK treats an empty map as "no start key", so DynamoDB starts again from the first item.

Here is that loop with a safety cap of five calls, on a customer with 20 orders and a limit of 8. The first item of each page it read:

calls 5, first item of each page [e00, e08, e16, e00, e08]

Page three is the last one, and page four starts over at e00. Without the cap, it reads the same 20 orders until something times out or someone notices the bill. The correct loop checks hasLastEvaluatedKey(), which is the SDK's way of telling an empty value from a missing one:

Map<String, AttributeValue> start = null;
do {
    QueryRequest.Builder b = QueryRequest.builder()
        .tableName("orders")
        .keyConditionExpression("customer = :c")
        .expressionAttributeValues(Map.of(":c", AttributeValue.fromS("ethan")))
        .limit(8);
    if (start != null) b.exclusiveStartKey(start);
    QueryResponse r = ddb.query(b.build());
    r.items().forEach(item -> System.out.println(item.get("order_id").s()));
    start = r.hasLastEvaluatedKey() ? r.lastEvaluatedKey() : null;
} while (start != null);

With one more line that prints each page's count and keys, run with SDK 2.57.3, it reads three pages and stops:

page 1: 8 items, hasLastEvaluatedKey()=true, lastEvaluatedKey()={order_id=AttributeValue(S=e07), customer=AttributeValue(S=ethan)}
page 2: 8 items, hasLastEvaluatedKey()=true, lastEvaluatedKey()={order_id=AttributeValue(S=e15), customer=AttributeValue(S=ethan)}
page 3: 4 items, hasLastEvaluatedKey()=false, lastEvaluatedKey()={}
last page: lastEvaluatedKey() == null? false, isEmpty()? true

You can skip the loop entirely with the paginator, which is the easiest correct option for code that reads everything. ddb.queryPaginator(request) returns an iterable of pages, and its items() walks every item across them; on the same data it made 3 pages and returned 20 items.

The Enhanced Client (the object mapper in dynamodb-enhanced) behaves the other way round, which is where copied code goes wrong. Its Page.lastEvaluatedKey() does return null on the last page:

DynamoDbTable<Order> table = enhanced.table("orders", TableSchema.fromBean(Order.class));
QueryConditional byCustomer = QueryConditional.keyEqualTo(k -> k.partitionValue("ethan"));

PageIterable<Order> pages = table.query(QueryEnhancedRequest.builder()
    .queryConditional(byCustomer).limit(8).build());

for (Page<Order> page : pages) {
    System.out.println(page.items().size() + " items, lastEvaluatedKey()=" + page.lastEvaluatedKey());
}
// 8 items, lastEvaluatedKey()={order_id=AttributeValue(S=e07), customer=AttributeValue(S=ethan)}
// 8 items, lastEvaluatedKey()={order_id=AttributeValue(S=e15), customer=AttributeValue(S=ethan)}
// 4 items, lastEvaluatedKey()=null

// resume one page for an API: pass a stored key back in
Page<Order> next = table.query(QueryEnhancedRequest.builder()
    .queryConditional(byCustomer).limit(8).exclusiveStartKey(storedKey).build())
    .iterator().next();

In the Enhanced Client, limit(8) is the page size, not a total: pages.items() still walks all 20 orders. To take only the first ten, stop the stream yourself with pages.items().stream().limit(10), which returned e00 to e09 and stopped reading. The resumed query above started at e08, exactly where page one ended.

Ethan, who had copied the low-level loop from an old project into a new one that used the Enhanced Client: "So one of them returns null and the other returns empty, and they're in the same SDK." He wrote hasLastEvaluatedKey() on a sticky note and put it on his monitor.

DynamoDB pagination in the AWS CLI: --max-items, --starting-token and --no-paginate

The AWS CLI hides pagination by default: it keeps sending requests until LastEvaluatedKey is gone and shows you the combined result. Counting the same 2,500 orders both ways shows the difference:

$ aws dynamodb query --table-name orders \
    --key-condition-expression "customer = :c" \
    --expression-attribute-values '{":c":{"S":"jake"}}' \
    --select COUNT
{"Count": 2500, "ScannedCount": 2500, "ConsumedCapacity": null}

$ aws dynamodb query ... --select COUNT --no-paginate
{"Count": 1582, "ScannedCount": 1582,
 "LastEvaluatedKey": {"order_id": {"S": "o01581"}, "customer": {"S": "jake"}}}

The flags that control it:

FlagWhat it does
(none)Fetches every page and merges them
--no-paginateSends one request and shows LastEvaluatedKey, like the raw API
--max-items NStops after N items in total and prints a NextToken
--starting-token TOKENResumes from a NextToken printed by an earlier --max-items run
--page-size NSets Limit on each underlying request; you still get all items, in more, smaller requests

Paging through results three at a time:

$ aws dynamodb query --table-name orders \
    --key-condition-expression "customer = :c" \
    --expression-attribute-values '{":c":{"S":"ethan"}}' \
    --projection-expression order_id --max-items 3
# Items: e00, e01, e02
# NextToken: eyJFeGNsdXNpdmVTdGFydEtleSI6IG51bGwsICJib3RvX3RydW5jYXRlX2Ftb3VudCI6IDN9

$ aws dynamodb query ... --max-items 3 --starting-token eyJFeGNsdXNpdmVTdGFydEtleSI6...
# Items: e03, e04, e05

As with boto3, the CLI's NextToken is the CLI's own token (it is built on boto3's paginator), not a LastEvaluatedKey. The first one above decodes to {"ExclusiveStartKey": null, "boto_truncate_amount": 3}: "start from the beginning, skip three". --page-size is useful when one big request would use a burst of read capacity or time out; it changes how many requests are sent, not what you get back.

PartiQL pagination: NextToken instead of LastEvaluatedKey

If you query DynamoDB with PartiQL, the SQL-like language behind ExecuteStatement and the console's PartiQL editor, pagination uses a different field. The response carries NextToken, a string, and no LastEvaluatedKey. You send the token back with the same statement until a response has none:

client = boto3.client("dynamodb")
kwargs = {"Statement": "SELECT order_id FROM orders WHERE customer = 'ethan'", "Limit": 8}
while True:
    page = client.execute_statement(**kwargs)
    for item in page["Items"]:
        print(item["order_id"]["S"])
    if "NextToken" not in page:
        break
    kwargs["NextToken"] = page["NextToken"]

On the 20-order customer, this made the same three calls as a Query:

call 1: 8 items, NextToken=yes, LastEvaluatedKey=None
call 2: 8 items, NextToken=yes, LastEvaluatedKey=None
call 3: 4 items, NextToken=no, LastEvaluatedKey=None

PartiQL does not escape the paging rules. A SELECT with no Limit on the 2,500 large orders returned 1,582 items and a NextToken, the same 1 MB page as the Query at the top of this guide, because PartiQL runs a query underneath. And Limit means "items to evaluate", not items to return. A statement whose WHERE clause did not use the key (WHERE status = 'open', with Limit 5) came back with zero items and a NextToken: it read five orders, none matched, and it stopped. That statement is also a full table scan: a PartiQL SELECT scans the whole table unless its WHERE clause has an equality or IN condition on the partition key, and the scan pages and costs like any other. If you want to forbid that, an IAM policy can allow PartiQL selects while denying full table scans.

A few things to know about the token:

  • It is a string, not a key map, so it cannot be used as an ExclusiveStartKey, and a key cannot be used as a NextToken.
  • A damaged token is rejected with ValidationException: Invalid NextToken.
  • Send it back with the identical statement and parameters. Treat it as tied to that statement, the way a start key is tied to its query.

The empty last page: why LastEvaluatedKey does not mean more data

This is the cause of the blank page behind Jake's "Load more" button, and of many "my loop makes one extra call" questions. A non-empty LastEvaluatedKey means only that the request stopped at a page boundary, the 1 MB limit or your Limit. It does not mean more matching items remain. The only reliable signal that you have reached the end is a response with no LastEvaluatedKey.

The simplest case shows it. A customer has exactly 20 orders, read with Limit=10:

call 1: 10 items, LastEvaluatedKey=yes
call 2: 10 items, LastEvaluatedKey=yes
call 3: 0 items, LastEvaluatedKey=no

The second call stopped because it hit Limit exactly at the last item, so DynamoDB returned a bookmark without knowing there was nothing after it. The third call found nothing. Your code has to handle that:

  • In a loop that reads everything, nothing special is needed: the empty page adds no items, and the loop ends.
  • In a user interface, do not render a "Load more" button purely because the last response had a bookmark. Either accept that the last click may return nothing (and hide the button then), or fetch one item more than you show (Limit of page size plus one) and show the button only if that extra item came back.
  • In an API, return an empty list with no next token, not an error, for that last call.

Ethan's fix for the shop used the "one extra" trick: ask for 21, show 20, and show "Load more" only when the 21st exists.

Pagination with a filter or a Limit: short pages and empty pages

A FilterExpression is applied after DynamoDB has read a page, and both the 1 MB rule and Limit count the items read, before filtering. So with a filter, pages can be short, or empty, while LastEvaluatedKey keeps telling you to continue. A partition of 300 orders in which 1 in 10 has status = open, queried with that filter and Limit=10:

pages: 31
first five pages (Count, ScannedCount): (1, 10), (1, 10), (1, 10), (1, 10), (1, 10)
total matches: 30

Thirty-one requests to find thirty items, one per page, because each page read 10 items and kept 1. Count is the number of items returned after the filter, ScannedCount the number read. This is why "DynamoDB pagination with a filter" so often looks broken: a page with Count: 0 and a LastEvaluatedKey is normal, and so is a page much smaller than Limit.

When you need "the first N matches", keep reading pages until you have N, with a larger Limit per request so you do not pay for dozens of tiny calls:

def first_n_matches(customer, n):
    kwargs = {"KeyConditionExpression": Key("customer").eq(customer),
              "FilterExpression": Attr("status").eq("open"),
              "Limit": 50}
    got = []
    while len(got) < n:
        page = table.query(**kwargs)
        got += page["Items"]
        if "LastEvaluatedKey" not in page:
            break
        kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]
    return got[:n]

On the same data, the first 10 matches took 2 calls instead of 10. Two caveats: if you return a page token from this function, base it on the last item you actually returned, not on the page's LastEvaluatedKey, or the next request skips the matches you cut off; and if a filter keeps very few items, the lasting fix is a key or index design that lets the query select them directly, because a filter does not reduce what you pay. The scan filter guide covers the Limit trap and filter syntax in depth.

Pagination for an API: turning LastEvaluatedKey into a page token

For a web or mobile app, the server reads one page per request and hands the client a token for the next one. The usual pattern is to encode LastEvaluatedKey as a URL-safe string (base64url of its JSON), send it as next, and decode it when it comes back as cursor. Here is a complete handler for a Lambda function behind a function URL or API Gateway:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const encode = (key) => key && Buffer.from(JSON.stringify(key)).toString("base64url");
const decode = (cur) => JSON.parse(Buffer.from(cur, "base64url").toString("utf8"));

export const handler = async (event) => {
  const customer = event.queryStringParameters?.customer;
  const cursor = event.queryStringParameters?.cursor;
  let startKey;
  if (cursor) {
    try { startKey = decode(cursor); } catch { return { statusCode: 400, body: "bad cursor" }; }
    if (startKey.customer !== customer) return { statusCode: 400, body: "cursor is for another customer" };
  }
  const out = await doc.send(new QueryCommand({
    TableName: "orders",
    KeyConditionExpression: "customer = :c",
    ExpressionAttributeValues: { ":c": customer },
    Limit: 8,
    ExclusiveStartKey: startKey,
    ProjectionExpression: "order_id",
  }));
  return { statusCode: 200, body: JSON.stringify({
    items: out.Items.map((i) => i.order_id),
    next: encode(out.LastEvaluatedKey) ?? null }) };
};

The first request returns eight orders and a token; the second, with ?cursor= that token, returns the next eight:

{"items":["e00","e01","e02","e03","e04","e05","e06","e07"],
 "next":"eyJvcmRlcl9pZCI6ImUwNyIsImN1c3RvbWVyIjoiZXRoYW4ifQ"}

{"items":["e08","e09","e10","e11","e12","e13","e14","e15"],
 "next":"eyJvcmRlcl9pZCI6ImUxNSIsImN1c3RvbWVyIjoiZXRoYW4ifQ"}

A reused token for another customer gets 400 cursor is for another customer, and a damaged one gets 400 bad cursor. Those checks matter more than they look:

  • A base64 token is not secret or tamper-proof. Anyone can decode eyJvcmRlcl9pZCI6ImUwNyIsImN1c3RvbWVyIjoiZXRoYW4ifQ to {"order_id":"e07","customer":"ethan"}. It reveals your key attribute names and values, so do not put anything in keys that you would not show the user.
  • Never let the token choose what is queried. The partition comes from the authenticated user or the request, and the token is only accepted if it belongs to that partition. DynamoDB itself rejects a start key from another partition (see errors), but your check gives a clean 400 instead of a 500.
  • If the key structure must stay private, sign or encrypt the token on the server, for example with an HMAC check, before trusting it.
  • Tokens are tied to the query. A token from one sort order, index or filter is not valid for another; include those in the check if your API lets the client choose them.

A "Load more" button in Next.js with DynamoDB

In a Next.js app (or any React front end), the DynamoDB call belongs on the server, so your AWS credentials never reach the browser. With the App Router, that is a route handler such as app/api/orders/route.js. The browser calls it with a cursor, appends the items it gets back, and hides the button when there is no next cursor. Here is the whole flow, in order:

  1. Query on the server. Put the DynamoDB client in the route handler, never in a client component.
  2. Ask for one item more than you show. With 8 per page, use Limit: 9. If 9 come back, there is a next page; if 8 or fewer, this is the end, and the button disappears without an empty extra click.
  3. Build the cursor from the last item you show, not from LastEvaluatedKey. With the "one more" trick, LastEvaluatedKey points at the ninth item, which you did not show; using it would skip that item on the next page.
  4. Check the cursor when it comes back. It must decode, and it must belong to the customer the request is for.
  5. In the browser, append and repeat. Keep the items in state, send next as ?cursor= on the next click, and hide "Load more" when next is null.
// app/api/orders/route.js
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const PAGE = 8;

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const customer = searchParams.get("customer");   // in a real app: from the signed-in session
  const cursor = searchParams.get("cursor");
  let startKey;
  if (cursor) {
    try { startKey = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); }
    catch { return Response.json({ error: "bad cursor" }, { status: 400 }); }
    if (startKey.customer !== customer) return Response.json({ error: "cursor is for another customer" }, { status: 400 });
  }
  const out = await doc.send(new QueryCommand({
    TableName: "orders",
    KeyConditionExpression: "customer = :c",
    ExpressionAttributeValues: { ":c": customer },
    Limit: PAGE + 1,
    ExclusiveStartKey: startKey,
  }));
  const items = out.Items.slice(0, PAGE);
  const last = items.at(-1);
  // more pages: the extra item came back, or the 1 MB limit cut the page short
  const more = out.Items.length > PAGE || out.LastEvaluatedKey !== undefined;
  const next = more && last
    ? Buffer.from(JSON.stringify({ customer: last.customer, order_id: last.order_id })).toString("base64url")
    : null;
  return Response.json({ items: items.map((i) => i.order_id), next });
}

Calling it three times in a row for the 20-order customer, each time with the previous next, gave:

page 1: 200 {"items":["e00","e01","e02","e03","e04","e05","e06","e07"],"next":"eyJjdXN0b21lciI6ImV0aGFuIiwib3JkZXJfaWQiOiJlMDcifQ"}
page 2: 200 {"items":["e08","e09","e10","e11","e12","e13","e14","e15"],"next":"eyJjdXN0b21lciI6ImV0aGFuIiwib3JkZXJfaWQiOiJlMTUifQ"}
page 3: 200 {"items":["e16","e17","e18","e19"],"next":null}
other customer's cursor: 400 {"error":"cursor is for another customer"}

Page three ends with next: null, so the button goes away after the last real page and the user never sees an empty one.

The more line covers the case that catches "one more" code in production: large items. If nine items add up to more than 1 MB, DynamoDB stops early and returns fewer than nine with a LastEvaluatedKey, and a check on the count alone would wrongly say "no more". With a page size of 2,000 on the 2,500 large orders, the handler returned 1,582 items with a next cursor, then the remaining 918 with next: null: all 2,500, none skipped.

In production, take the customer from the signed-in session rather than from the URL, so nobody can ask for someone else's orders by changing a parameter. The same handler works as a server action or in a Pages Router API route; only the request and response objects change. If the list is filtered, the "one more" trick still works but may need several reads to fill a page (see the filter section), so loop inside the handler until you have nine items or no LastEvaluatedKey.

Paginating a GSI query, and sorting newest first

Querying a global secondary index paginates the same way, with one difference in the bookmark: LastEvaluatedKey from an index holds the index's key attributes and the table's primary key. From an index on status (partition) and created (sort):

{'order_id': 'e04', 'created': '2026-10-01T00004', 'customer': 'ethan', 'status': 'open'}

Four attributes instead of two, because many items can share the same index key, and DynamoDB needs the table key to know exactly where it stopped. Pass the whole thing back unchanged as ExclusiveStartKey with the same IndexName; do not build it yourself from the index keys alone. Remember also that global secondary indexes support only eventually consistent reads, so a page from a GSI can briefly miss an item written a moment ago.

Newest first is a sort-key matter, not a pagination one. Set ScanIndexForward=False and pages come in descending sort-key order; the token works the same way, and each page continues where the previous one stopped:

page1 ['e19', 'e18', 'e17']
page2 ['e16', 'e15', 'e14']

Choose the direction once per listing and keep it for every page; a token from a descending query used in an ascending one jumps to the wrong place.

Page numbers, previous page and total counts

Three features that SQL databases make easy have no direct equivalent in DynamoDB, and it is better to design for that than to fight it.

Jumping to page 7. There is no offset. To reach page 7 you must read pages 1 to 6 first, because each page's start depends on where the previous one stopped. For lists people browse, use "Load more" or "Next" instead of numbered pages; for lists people search, narrow the query with the key condition (a date range on the sort key, for example) instead of paging deep.

Previous page. DynamoDB tokens only go forward. The simple approach is for the client to keep the tokens it has seen, so "back" re-requests the earlier page with its stored token. Alternatively, run the query in the opposite direction from the first item on the current page, which takes more care to get exactly right.

"Showing 1 to 20 of 2,500". A total requires reading every matching item. Select="COUNT" returns only the number, but it uses the same read capacity as fetching the items, and it paginates too:

total = 0
for page in paginator.paginate(TableName="orders",
                               KeyConditionExpression="customer = :c",
                               ExpressionAttributeValues={":c": {"S": "jake"}},
                               Select="COUNT"):
    total += page["Count"]
print(total)   # 2500, in 2 pages

For the first 1 MB page of those orders, DynamoDB Local reported the same consumed capacity for the full-item query and the COUNT query: 128.5 read capacity units each. That is roughly what 1 MB costs: 256 chunks of 4 KB at half a unit each for an eventually consistent read. If you show totals often, store the count instead: keep a counter item per customer and update it in the same transaction that adds or removes an order, or from a DynamoDB Streams consumer.

What DynamoDB pagination costs: read units per page

Pagination itself is free, but the way you split the pages changes the bill a little, and the reason is rounding. For a Query, DynamoDB adds up the size of all the items a request reads and rounds that total up to the next 4 KB. An eventually consistent read costs half a read unit per 4 KB, a strongly consistent read one full unit. Every request rounds separately, so many small pages pay the rounding many times.

Reading all 2,500 of Jake's orders (about 650 bytes each, roughly 1.6 MB in total) in different ways, with the consumed capacity each request reported:

How the 2,500 orders were readRequestsRead unitsOn-demand cost per million full reads
No Limit (1 MB pages)2203$25.38
Limit 10026212.5$26.56
Limit 21 ("Load more" pages)120238.5$29.81
No Limit, ConsistentRead=True2406$50.75
Select="COUNT" (number only)2203$25.38

Costs use the on-demand price in US East (N. Virginia) in the AWS price list for October 2026: $0.125 per million read request units. Three things stand out:

  • Small pages cost more for the same data. 120 pages of 21 used 17 percent more read units than 2 big pages, because each request rounds up to 4 KB on its own. For an interactive list that is the right trade (nobody wants 1,582 orders on one screen), but a batch job that reads everything should leave Limit off or set it high.
  • Counting costs the same as reading. COUNT saves network transfer, not read units, because DynamoDB still reads every item to count it.
  • Strong consistency doubles it. Use ConsistentRead only where a page must include a write made a moment ago.

One "Load more" page is cheap: 21 of these orders cost 2 read units, which is $0.25 per million page loads on demand. Twenty small orders of a few dozen bytes each fit inside one 4 KB chunk and cost 0.5 units for the whole page. Two details people expect to save money do not. A ProjectionExpression that returns only order_id cost the same 128.5 units for the first page and still stopped at 1,582 items, because both the 1 MB limit and the cost are measured on the items read, not on what you get back. And an empty page is not free: a query that finds nothing still consumes read capacity, which is one more reason to stop when LastEvaluatedKey is missing rather than probing for more. On a provisioned table, the same units come out of your read capacity per second instead of the bill; on-demand vs provisioned compares the two.

Items that change between pages, and consistent reads

A paginated read is not a snapshot. Each page reads the table as it is at the moment of that request, starting just after the bookmark. That has predictable effects when items are added, deleted or changed while someone is paging.

After reading the first page of Ethan's 20 orders (e00 to e07, bookmark at e07), three changes were made before reading the rest: a new order e05a that sorts before the bookmark, a new order e17a that sorts after it, and order e10 deleted. The remaining pages returned:

later pages: ['e08', 'e09', 'e11', 'e12', 'e13', 'e14', 'e15', 'e16', 'e17', 'e17a', 'e18', 'e19']
  • New items after the bookmark appear (e17a).
  • New items before the bookmark are never seen in this pass (e05a).
  • Deleted items simply do not come back (e10), and nothing is repeated.

A bookmark also survives the deletion of the item it points to. The start key does not have to exist: starting from the deleted e10 returned e11, e12, e13. So a token you handed to a browser an hour ago still works after that order is removed.

Indexes add one more case. When you page through a GSI and an item's index sort key changes mid-way, the item moves. On an index sorted by created, order e03 (already shown on page one) was given a later timestamp and e12 (not yet shown) an earlier one. The rest of the pass showed e03 a second time and never showed e12. If the list is sorted by something that changes, such as "last updated" or a status, expect occasional duplicates and gaps, and de-duplicate by the table key on the client.

Consistent reads work per page: add ConsistentRead=True to each Query to see writes that finished just before that page, at twice the read cost. Global secondary indexes do not support them; asking returns ValidationException: Consistent reads are not supported on global secondary indexes. If you need a true point-in-time copy of a large table, for an audit or a migration, export the table to S3 instead of paginating it.

Jake asked whether that meant the order history could be "wrong". Ethan's answer: "It's never wrong about what it shows. It just doesn't stop the world while you scroll."

Paginating a Scan, and parallel scans

Scan paginates exactly like Query: the same 1 MB and Limit rules, the same LastEvaluatedKey and ExclusiveStartKey, and filters are applied after the page is read. The difference is scope: a scan reads the whole table or index, so on a large table, reading every page can take a long time and a lot of capacity. A scan uses the same read capacity whether or not you add a filter.

For a large table that you really must read in full (an export, a backfill, a migration), a parallel scan splits the work. Each worker sends its own Scan with Segment set to its number (0, 1, 2, ...) and TotalSegments set to the number of workers, and paginates its own segment with its own LastEvaluatedKey:

def scan_segment(segment, total_segments):
    kwargs = {"Segment": segment, "TotalSegments": total_segments}
    while True:
        page = table.scan(**kwargs)
        yield from page["Items"]
        if "LastEvaluatedKey" not in page:
            break
        kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]

Run one call per worker (threads, processes or separate Lambda invocations). Keep each segment's bookmark separate; a token from segment 2 means nothing to segment 3. Parallel scans finish sooner but consume capacity faster, so on a provisioned table watch for throttling, and on any table prefer a query, or an export to S3, when you can.

Pagination errors and what they mean

Most pagination bugs show no error at all; they just return too few items. The ones that do raise errors come from a start key that does not fit the request. These are the messages returned for the common mistakes:

ErrorWhat happenedFix
ValidationException: The provided starting key does not match the range key predicateThe ExclusiveStartKey belongs to a different partition or sort-key range than the query's key conditionUse the token only with the query that produced it; validate tokens from clients
ValidationException: Exclusive Start Key must have same size as table's key schemaThe start key is missing a key attribute (here, only order_id without customer)Pass the whole LastEvaluatedKey back unchanged
Invalid type for parameter ExclusiveStartKey ... valid types: <class 'dict'> (boto3)A string, usually a paginator or CLI NextToken, was passed where a key map is expectedUse NextToken with StartingToken or --starting-token, not as a start key
Invalid type for parameter ExclusiveStartKey.customer ... valid types: <class 'dict'> (boto3)A key from the resource interface (plain values) was passed to the low-level client, which expects typed values such as {"S": "ethan"}Keep using the same interface that produced the key
ValidationException: The provided starting key is invalidOn a GSI query, the start key has only the index keys and is missing the table's primary key attributesPass the full LastEvaluatedKey from the index query, all four attributes
ValidationException: Consistent reads are not supported on global secondary indexesConsistentRead=True on a GSI queryDrop it for the index, or query the table (or an LSI) when you need strong consistency
Invalid value for parameter Limit, value: 0, valid min value: 1 (boto3)Limit set to 0, often from a page-size variable that was never filledUse 1 or more, or leave Limit out
ValidationException: Invalid NextTokenA damaged or hand-made PartiQL NextTokenSend back exactly the token from the previous ExecuteStatement response

If you see ProvisionedThroughputExceededException while paginating, the pages are fine; you are reading faster than the table's capacity allows. Slow down, use --page-size or a smaller Limit, or look at hot partition throttling if one partition takes all the reads.

DynamoDB pagination best practices

  1. Loop on the presence of LastEvaluatedKey, never on the item count, and expect empty and short pages.
  2. Treat the start key as opaque. Pass back exactly what DynamoDB returned; do not build it by hand, and keep the same interface (resource or client) that produced it.
  3. Set Limit to what one screen or one API page needs, plus one if you want to know whether a next page exists.
  4. Prefer key conditions over filters to select items; filters do not reduce what you pay and make pages unpredictable.
  5. Validate tokens from clients against the user's partition and the query's options, and sign them if the key layout must stay private.
  6. Avoid page numbers and live totals; use "Load more", key ranges, and stored counters.
  7. Use ProjectionExpression for list views. It does not lower read capacity (that depends on item size), but it shrinks responses and speeds up the network part.
  8. Test with more than 1 MB of data, and with a partition whose size is an exact multiple of your Limit, so both edge cases show up before production. DynamoDB Local in Docker makes that cheap.

The same pagination thinking applies across AWS: S3 listings stop at 1,000 keys and return a continuation token (listing more than 1,000 S3 objects), and most "list" APIs have a NextToken. Once you have written one correct loop, they all look the same.

Jake asked whether all of this was overkill for an order history. Ethan showed him the customer from March: 1,700 orders, and every one of them now loaded. "It's not overkill if she's your best customer," he said.

Troubleshooting by symptom

My query returns only part of the items

You read one page. Loop on LastEvaluatedKey or use a paginator; the 1 MB limit applies even without Limit.

The last "Load more" returns nothing

Normal when the previous page ended exactly at the last item. Fetch one extra item to know whether a next page exists.

A page has Count 0 but a LastEvaluatedKey

Your filter removed every item read on that page. Keep paginating.

My pages are much smaller than Limit

Limit counts items read before the filter. Raise Limit, or select items with the key condition instead.

Pages repeat or skip items

The start key or the query changed between pages. Use the exact LastEvaluatedKey with the same key condition, index, filter and sort direction.

ValidationException about the starting key

The token came from another partition, query or interface. Validate tokens and pass them back unchanged.

The CLI NextToken does not work in my code

It is a CLI and boto3 paginator token, not a start key. Use it only with --starting-token or StartingToken.

Counting items is slow and expensive

COUNT reads every item. Store a counter item, updated with each write, if you show totals.

Frequently asked questions

Does DynamoDB support pagination?

Yes. Query and Scan return results in pages of up to 1 MB or Limit items, with LastEvaluatedKey to request the next page as ExclusiveStartKey.

How does DynamoDB pagination work?

Each response that stops early includes LastEvaluatedKey. Send it back as ExclusiveStartKey with the same query, and repeat until a response has no LastEvaluatedKey.

What is LastEvaluatedKey in DynamoDB?

The primary key of the last item the request read. It marks where the page stopped, so the next request can continue just after it.

What is the LastEvaluatedKey format?

A map of the key attributes only: the table's partition and sort keys, plus the index keys when you query a GSI. The low-level API adds type markers such as S.

What is ExclusiveStartKey?

The request parameter that takes a previous LastEvaluatedKey. DynamoDB starts reading after that key, without returning it again.

Is there a DynamoDB pagination example in Python?

Yes: loop on table.query, yield page["Items"], and set ExclusiveStartKey to page["LastEvaluatedKey"] until it is missing, or use client.get_paginator("query").

How do I paginate DynamoDB in JavaScript?

Loop with QueryCommand and ExclusiveStartKey, or use paginateQuery from @aws-sdk/lib-dynamodb with for await.

Why does LastEvaluatedKey exist when there are no more items?

The page ended at Limit or 1 MB exactly at the last item. The next call returns nothing and no LastEvaluatedKey.

Does Limit return that many items in DynamoDB?

No. Limit caps the items read per request, before filters, so with a filter you can get fewer, or none.

What is the DynamoDB page size limit?

1 MB of data read per Query or Scan request, regardless of Limit.

How do I get the total count with pagination?

Use Select COUNT and add Count across all pages, which costs the same as reading the items, or keep a stored counter.

Can I jump to a page number in DynamoDB?

No. There is no offset; you must read the earlier pages, so use next and previous tokens or narrow the query instead.

How do I paginate a DynamoDB scan?

The same way as a query: pass LastEvaluatedKey as ExclusiveStartKey. For large tables, a parallel scan uses Segment and TotalSegments.

How do I paginate a GSI query?

Pass the whole LastEvaluatedKey, which includes the index and table keys, back as ExclusiveStartKey with the same IndexName.

Is it safe to send LastEvaluatedKey to the browser?

Only as a token you validate. Base64 is readable, so do not expose sensitive key values, and sign the token if needed.

How does the AWS CLI handle DynamoDB pagination?

It fetches all pages by default. Use --max-items and --starting-token to page, --no-paginate for one request, --page-size for smaller requests.

What is the difference between NextToken and LastEvaluatedKey?

LastEvaluatedKey is DynamoDB's key; NextToken is a token from the CLI or the boto3 paginator for its own StartingToken.

Does pagination cost more in DynamoDB?

Slightly, with small pages. Each request rounds its data up to 4 KB, so 120 pages of 21 items cost 17 percent more read units than 2 large pages.

How do I paginate DynamoDB in Java?

Use hasLastEvaluatedKey() in a loop, or queryPaginator(request).items(). In SDK 2.x, lastEvaluatedKey() is an empty map on the last page, never null.

Why does my Java DynamoDB pagination loop never end?

It checks lastEvaluatedKey() != null. The low-level SDK 2.x returns an empty map instead of null, so the loop restarts from the first item. Check hasLastEvaluatedKey().

How does PartiQL pagination work in DynamoDB?

ExecuteStatement returns NextToken instead of LastEvaluatedKey. Send it back with the same statement until it is missing; the 1 MB and Limit rules still apply.

Does ExclusiveStartKey have to be an existing item?

No. It is a position, not a lookup. A start key for a deleted item still works, and reading continues with the next key after it.

Can I change Limit between pages?

Yes. Limit applies to each request separately, so the next page can use a different Limit with the same start key.

Does ProjectionExpression give me more items per page?

No. The 1 MB page limit and the read cost are measured on the full items read, not on the attributes returned.

What happens if items are added while I paginate?

New items after the current bookmark appear in later pages; items added before it are not seen in that pass. Deleted items just do not come back.

Is DynamoDB query case sensitive?

Yes. Key values are compared exactly, so a query for "Ethan" does not find items stored under "ethan".

Is backward and forward pagination possible at the same time in DynamoDB?

Yes, with care. Keep the tokens of pages already seen for "back", or query in the opposite sort direction from the first item of the current page.

How do you do secure pagination in DynamoDB?

Take the partition from the signed-in user, never from the token, check that the token belongs to that partition, and sign or encrypt the token if keys are sensitive.

How reliable are DynamoDB Limit and LastEvaluatedKey?

Completely, once you know the rules: Limit counts items read before filters, pages also stop at 1 MB, and only a missing LastEvaluatedKey means the end.

How do you sort, filter and paginate in DynamoDB?

Sort with the sort key or an index, select with the key condition, and filter last. Filters run after each page is read, so filtered pages come back short.

How do you handle an API GET with pagination in Lambda and DynamoDB?

Return one page per request with a URL-safe token made from the last key, accept it as a cursor parameter, validate it, and stop when the token is null.

How do I add "Load more" in Next.js with DynamoDB?

Query in a route handler with Limit one higher than the page size, return a cursor made from the last item shown, and hide the button when the cursor is null.

For readers preparing for an AWS certification

We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.

Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03) • Data Engineer – Associate • CloudOps Engineer – Associate (SOA-C03).

Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:

1. A DynamoDB Query with no Limit returns 1,582 of a partition's 2,500 items and includes LastEvaluatedKey. What happened, and what should the code do?

The response hit the 1 MB page limit. Send LastEvaluatedKey back as ExclusiveStartKey and repeat until a response has no LastEvaluatedKey.

2. A Query with Limit=10 and a FilterExpression returns Count 0 but a LastEvaluatedKey. Why?

Limit counts items read before the filter is applied; none of the 10 items read matched. Keep paginating, or select the items with the key condition or an index instead.

3. An application must show the total number of items in a large partition on every page view. What is the cost-effective design?

Keep a counter item updated with each write (in the same transaction or from a DynamoDB Streams consumer); Select COUNT reads every item and costs the same as reading them.

4. A developer adds ConsistentRead=true to a paginated query on a global secondary index. What happens?

It fails with a ValidationException: GSIs support only eventually consistent reads. Query the base table or an LSI for strong consistency.

5. A mobile API returns LastEvaluatedKey to clients as a page token. Which controls stop a client from reading another user's data?

Take the partition key from the authenticated identity, reject tokens whose key belongs to another partition, and sign or encrypt the token if the key values are sensitive.

6. Which change reduces the read capacity consumed when reading an entire partition with pagination?

Fewer, larger pages (a higher Limit or none), because each request rounds up to 4 KB; ProjectionExpression does not reduce read units.

Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.

The "Load more" button on Jake's order history now asks for twenty-one orders and shows twenty. Jake still does not know what a LastEvaluatedKey is, which Ethan considers proof that the fix worked.

📌 If you keep one line from this page

Send each LastEvaluatedKey back as ExclusiveStartKey and stop only when it is missing: pages end at 1 MB or Limit items read, before filters, and the last page can be empty.

Examples run with boto3 1.43, AWS SDK for JavaScript 3.1156.0 on Node.js 24, AWS SDK for Java 2.57.3 and AWS CLI 1.46 against DynamoDB Local 3.3.1, October 2026. Prices: AWS price list, US East (N. Virginia), October 2026.

Revision note. Written October 23, 2026, for DynamoDB as it works in October 2026. "Don't count your chickens before they hatch," the proverb says; with DynamoDB, don't count your items before the last page.

Related