Why Keyset Pagination Beats Offset — Especially for Your Bill
Offset pagination asks the engine to walk past everything you already saw and throw it away; keyset pagination asks it to seek straight to where you stopped. The work is identical for page 1 and wildly different for page 500 — and on a store that meters reads by rows examined, that difference shows up as a line item. Walking 100,000 documents by offset examines about 50 million rows. By keyset, it examines 100,000.
The two shapes
Offset says skip 4,900 results, then give me 100. The engine has no way to jump to the 4,901st match without establishing which 4,900 came before it, so it walks them and discards them. Page 50 costs fifty pages of work to return one page of rows.
Keyset (also called seek pagination) says give me the next 100 results after this value. You carry the sort value of the last row you saw and turn it into a filter. Because the sort field is indexed, the engine seeks directly to that position and reads forward. Page 50 costs exactly what page 1 costs.
The shapes are not equivalent in what they can express — offset can jump to an arbitrary page number and keyset cannot. We'll get to that. But for the thing most pagination actually does — walking forward through a list, an infinite scroll, a nightly export, a backfill — keyset does strictly less work for the same output.
Why the difference lands on your bill
Datastore meters reads by rows examined: one read covers up to 100 rows scanned, floored at one read per request. That single sentence is what turns an algorithmic footnote into an invoice. You are not billed for rows returned — you are billed for rows the engine had to look at to find them. Offset's discarded rows were looked at.
Take a collection of 100,000 documents and walk the whole thing at 100 documents per page. Reads are $0.50 per million (the pricing page lists it as $0.05 per 100,000).
| Approach | Rows examined | Reads | Cost |
|---|---|---|---|
| Offset | 50,050,000 | 500,500 | $0.25 |
| Keyset | 100,000 | 1,000 | $0.0005 |
Offset examines 100 rows on page 1, 200 on page 2, 300 on page 3, and 100,000 on the last page. Summed across 1,000 pages that's 50,050,000 rows. Keyset examines 100 rows on every page: 100,000 rows total, which is simply the size of the collection, because each document is read once. Five hundred times the work for the same 100,000 documents.
It scales with collection size, not with traffic
The offset total is roughly n² / 2p for n documents at page size p. Keyset is n. That exponent is the whole story, and it is why this bug ships: it is invisible in development and invisible in staging.
| Collection | Offset, one full walk | Keyset, one full walk |
|---|---|---|
| 10,000 docs | 505,000 rows · $0.0025 | 10,000 rows · $0.00005 |
| 100,000 docs | 50,050,000 rows · $0.25 | 100,000 rows · $0.0005 |
| 1,000,000 docs | 5,000,500,000 rows · $25.00 | 1,000,000 rows · $0.005 |
Ten times the data, a hundred times the bill. A single one-off export is cheap either way — all of these sit inside the $3 of free usage every organization gets each month. The offset column becomes real when the walk is a nightly job (multiply by 30) or when it's a user-facing list that thousands of people scroll. And the latency curve is the same curve: your page-500 request is slow for exactly the reason it is expensive.
The keyset query shape
On altengine's Datastore you mostly don't hand-roll this, because the query body has no offset field to reach for. It takes where, order, limit, and cursor — and the cursor is keyset pagination, handed to you opaque. Run a query, pass the returned cursor back on the next request, stop when it comes back null.
POST /v1/datastore/app/ns/acme/col/orders/query
{
"where": [{ "field": "status", "op": "=", "value": "open" }],
"order": [{ "field": "__updated__", "dir": "desc" }],
"limit": 100
}
# → { "documents": [ … ], "cursor": "…"|null }
# next page
{ "where": [ … ], "order": [ … ], "limit": 100, "cursor": "…" }Sometimes you want the boundary in your own hands — to persist a resume point, to deep-link a position, to hand a range to a worker, or to restart a backfill that died at 3am. Then write the keyset predicate yourself: filter on the sort field with the last value you saw.
POST /v1/datastore/app/ns/acme/col/orders/query
{
"where": [
{ "field": "status", "op": "=", "value": "open" },
{ "field": "total", "op": "<", "value": 240 }
],
"order": [{ "field": "total", "dir": "desc" }],
"limit": 100
}Descending order means the next page is everything < the last total you saw; ascending means >. Either way, one filter replaces the skip.
This only seeks if an index serves it. Filtering on status and sorting by total wants a composite index on both, in that order:
POST /v1/datastore/app/ns/acme/col/orders/indexes
{ "fields": ["status", "total"] }Without it you get a 400 with INDEX_REQUIRED — or, if auto-indexing is still on, the engine builds the index it would have suggested, runs your request anyway, and reports it back as auto_indexed. Convenient while you're building; in production most teams turn it off per instance so nothing creates an index they didn't plan. Full details are in the Datastore API reference.
What keyset actually costs you
It is not free, and pretending otherwise would be the same overclaim as ignoring the bill in the first place.
- No jumping to page 47. Keyset gives you next, and previous if you keep both boundary values. If your UI is a numbered page picker, keyset doesn't produce one. Infinite scroll, a "load more" button, and cursor-driven exports are all natural fits; a numbered picker is not.
- The sort needs a total order. If two documents share a
totalexactly at a page boundary, a hand-rolled<can skip one or repeat one. Either page on a field that's unique per document, or use the built-incursor, which doesn't have this problem. - No free total count. Offset UIs usually get "page 3 of 47" for nothing. Keyset doesn't. Datastore's
aggregateendpoint will give you acount, but it's a separate request that pays for the rows it scans, so don't run it on every page — run it once and cache it, or drop the count.
The upside beyond cost: keyset is also more correct under concurrent writes. If someone inserts a document ahead of your position mid-walk, offset shifts everything down by one and you silently re-read a row you already processed. A keyset boundary is anchored to a value, not to a count, so it doesn't drift.
Page size is the other lever
Because a read is floored at one per request, tiny pages waste money from the opposite direction. Walking 100,000 documents at the default limit of 25 is 4,000 requests, and the row-based meter (1,000 reads) never gets to apply — the per-request floor charges you 4,000. At limit: 100 the two meet. Query limit maxes at 500, so for a backfill, page big.
Note the asymmetry: raising the page size halves offset's total work but leaves keyset's rows examined unchanged at n. Keyset's cost is a property of your data; offset's is a property of how you asked.
What about Search?
The Search API does expose offset, because App Engine's Search API did and parity of semantics is the contract — your existing queries carry over. But it's capped at 1,000, which is the guardrail: use it for a shallow page picker, and follow the response cursor for anything deeper. Search reads meter the same way, by rows examined at 100 per read, on top of a flat $0.50 per 10,000 queries. One nice consequence there: a repeated identical query is served from cache and bills zero reads — just its query — and a write to the index invalidates that cache immediately, so you never read stale results.
The short version
Use the cursor. It's the default path on Datastore for a reason, and it costs what the data costs rather than what the page number costs. Hand-roll a keyset predicate when you need to own the boundary, index the field you sort on, page at 100 to 500 rather than 25, and fetch your total count once instead of on every page. Then the fast query and the cheap query are the same query — which is the only pagination advice that survives your collection getting big.
← Back to the blog