DynamoDB Pagination: LastEvaluatedKey Explained
How DynamoDB pagination works with LastEvaluatedKey and ExclusiveStartKey, page sizes with Limit, and patterns for paging through Query and Scan results.
On this page
DynamoDB pagination is cursor-based, not offset-based. Instead of “give me page 5,” you hand the next request the key where the last one stopped. The mechanism is two fields — LastEvaluatedKey in the response and ExclusiveStartKey in the next request — plus a Limit that controls page size.
This guide explains how those pieces fit together, the two ways a page can end, and the patterns that keep paging correct and cheap.
The core loop
Every Query and Scan can return a LastEvaluatedKey. If present, it’s the primary key of the last item DynamoDB evaluated, and it means there may be more results. To get the next page, pass it back as ExclusiveStartKey. When the response omits LastEvaluatedKey, you’ve reached the end.
params = {
"TableName": "Orders",
"KeyConditionExpression": "PK = :pk",
"ExpressionAttributeValues": {":pk": {"S": "USER#123"}},
"Limit": 25,
}
while True:
resp = client.query(**params)
for item in resp["Items"]:
handle(item)
if "LastEvaluatedKey" not in resp:
break
params["ExclusiveStartKey"] = resp["LastEvaluatedKey"]
That loop is the whole model. The subtleties are in why a page ends and how big a page is.
Limit caps items evaluated, not returned
Limit is the number of items DynamoDB evaluates before it stops, not the number it returns to you. This distinction matters the moment you add a FilterExpression.
A filter is applied after items are read from the table. So if you set Limit = 25 and 20 of those 25 items fail the filter, you get 5 items back — plus a LastEvaluatedKey telling you to keep going. The classic bug is treating “fewer than Limit items” as “end of results.” It isn’t.
# Wrong: stops early on a sparse filtered page
resp = client.query(Limit=25, FilterExpression="...")
if len(resp["Items"]) < 25:
done() # BUG — there may be more pages
# Right: trust LastEvaluatedKey, not item count
if "LastEvaluatedKey" not in resp:
done()
Because filters discard data you already paid to read, prefer encoding selectivity into your keys. See query vs scan and expressions for how filters bill against read capacity.
The 1 MB page ceiling
There’s a second, non-negotiable reason a page ends: each Query or Scan returns at most 1 MB of data before any filter is applied. Even with no Limit set, DynamoDB stops at 1 MB and hands back a LastEvaluatedKey.
This means:
- A single logical “query” of a large partition is always paginated under the hood once it crosses 1 MB.
- You cannot disable it. Code that ignores
LastEvaluatedKeysilently processes only the first ~1 MB and drops the rest.
So even when you don’t think you’re paginating, you might be. Always handle the cursor.
| Reason a page ends | Triggered by | LastEvaluatedKey present? |
|---|---|---|
Hit Limit items evaluated | Limit parameter | Yes (if more remain) |
| Hit 1 MB of read data | Always enforced | Yes (if more remain) |
| Exhausted the result set | End of data | No |
Choosing a page size
Limit is your lever for predictable latency and capacity per call. Smaller pages mean more round trips but lower, steadier per-request cost — useful for interactive UIs. Larger pages reduce round trips for batch jobs.
A few guidelines:
- For user-facing lists, match
Limitto what fits on screen (e.g. 20–50). The user paginating naturally throttles your reads. - For background processing, omit
Limitand let the 1 MB ceiling size pages; just loop onLastEvaluatedKey. - Remember
Limitwon’t save you money on a filtered Scan — you still pay for everything evaluated.
Passing cursors to clients
The LastEvaluatedKey is a map of attribute-value pairs (the primary key, plus index keys for GSI/LSI queries). To expose pagination over a stateless API, serialize it into an opaque cursor token for the client to send back.
import base64, json
def encode_cursor(key):
return base64.urlsafe_b64encode(json.dumps(key).encode()).decode()
def decode_cursor(token):
return json.loads(base64.urlsafe_b64decode(token))
Treat the token as opaque on the client side — don’t let callers construct or mutate it. Note that the key can include attributes beyond the table’s primary key when querying an index, so always round-trip the entire LastEvaluatedKey, not just PK/SK.
What you can’t do: random page access
Cursor pagination is forward-only. There is no offset, no OFFSET 100, no “jump to page 5.” If a user clicks page 5, you either:
- Walk the pages. Sequentially fetch pages 1–4 (and cache cursors) to reach 5. Cheap for shallow paging, wasteful for deep paging.
- Design a seekable sort key. If your SK encodes something sortable and predictable (a timestamp, a sequence number), you can start a Query with a
KeyConditionExpressionlikeSK >= :positionto jump directly — effectively keyset pagination.
Option 2 is the scalable answer. Offset pagination doesn’t exist in DynamoDB by design, because skipping N items would still cost you reading N items.
SDK paginators
Most AWS SDKs ship a paginator that runs the loop for you, transparently following LastEvaluatedKey:
paginator = client.get_paginator("query")
for page in paginator.paginate(
TableName="Orders",
KeyConditionExpression="PK = :pk",
ExpressionAttributeValues={":pk": {"S": "USER#123"}},
):
for item in page["Items"]:
handle(item)
Paginators are convenient for batch jobs but will happily run until the entire result set is consumed — fine for processing, dangerous if you forget you’re iterating a 50 GB partition. For user-facing pagination you still want explicit Limit and cursor handoff.
Inspecting pagination while you build
Pagination bugs are easy to write and hard to see, because the wrong code often returns some data and looks like it works. A DynamoDB GUI such as Tablyne shows the LastEvaluatedKey for each page, the consumed read capacity, and how many items a filter discarded versus returned — which makes “this query is silently truncating at 1 MB” obvious instead of a production surprise.
The rules are short: loop until LastEvaluatedKey is absent, never trust item count as an end signal, and accept that paging is forward-only. Get those three right and DynamoDB pagination is boringly reliable — which is exactly what you want. For more on shaping efficient reads, see the best practices guide.
Frequently asked questions
Why does my DynamoDB query return fewer items than Limit?
Limit caps items evaluated, not returned, and each page is also capped at 1 MB. A FilterExpression runs after the read, so a page can come back nearly empty yet still include a LastEvaluatedKey — keep paging until it's absent.
Can I jump to page 5 directly in DynamoDB?
Not natively. Pagination is forward-only via LastEvaluatedKey, so you can't skip to an arbitrary offset. To approximate page jumps you must either walk pages sequentially or design a sort key that encodes a seekable position.
Does an empty LastEvaluatedKey mean there are no more items?
Yes. When DynamoDB omits LastEvaluatedKey from the response, you've reached the end of the result set. A present LastEvaluatedKey means more items may exist, even if the current page returned zero items after filtering.