See It Flow

Core Concepts

Pagination

Pagination is not about limiting rows. It is about controlling database work. The choice of strategy determines query cost, consistency, and scalability.

Mangesh MandlikLast updated:

What Is Pagination, and Why Do APIs Need It?

Bounded responses, and the four questions every strategy has to answer

Pagination divides a potentially large result set into bounded responses, so a client reads a long list one page at a time instead of receiving every row at once. It is the main tool an API has for keeping the work per request, and therefore latency and memory, predictable. It is also a consistency problem: while a client walks a list, the list keeps changing, and the pagination scheme decides which rows that client will see, miss, or see twice. API pagination is therefore a contract between server and client about ordering, page boundaries, and what a "next page" token promises, not just a LIMIT clause.

Consider GET /orders on a table with 50 million rows and no paging rule. The database scans and serialises everything that matches, the application holds the whole result in memory, the response saturates the network connection, and the client either freezes or runs out of memory while parsing it. Even when one such request survives, a handful of them in parallel is enough to degrade the database for every other user. Returning only what the screen needs, quickly, protects the database as much as it helps the client. Pagination is far easier to design in from the first version of a list endpoint than to retrofit into one that clients already depend on, because a "return everything" response cannot gain a page limit without breaking them.

A pagination contract is complete only when it answers four questions, and every strategy in this lesson is a different set of answers to them:

  1. How are records ordered? A page is a slice of an ordered sequence, so the order must be total and deterministic. If two rows can tie, the database may return them in either order on different calls.
  2. How does the client identify the next page? By counting (skip N rows), or by position (continue after this row). The first is positional arithmetic; the second is an anchor into the data itself.
  3. What happens when rows are inserted or deleted between requests? Each request is a separate read. Unless you do something extra, nothing ties request two to the data request one saw.
  4. How much work must the database do to find each page? Finding the first row of a page can cost far more than returning the twenty rows on it.

The mental model to carry through the rest of the article is the difference between a count and an anchor. Offset pagination says "skip this many rows", a number that means something only relative to the list as it was an instant ago. Keyset and cursor pagination say "continue after this row", a position that stays meaningful when rows are added or removed elsewhere. The count is simple and supports page numbers; the anchor is cheaper to resolve and steadier under change. For where the cursor and page-size parameters live in an HTTP list endpoint, see REST; for the contract conventions that apply to every endpoint, see API Design.

Visual Story

No pagination → offset → offset cost → data drift → cursor → cursor stability — watch the full sequence

Pagination — Offset vs Cursor

Pagination — Offset vs Cursor
No Pagination
0 / 0
📄Up nextOffset Pagination

⤢ Expand for synchronized narration timeline

Offset Pagination: Simple, but Increasingly Expensive

Skip N rows, return the next page; cost grows with how far you skip

Offset pagination expresses the page as a count of rows to skip. The query below returns the third page of twenty orders for one customer:

Offset pagination: page 3, page size 20
SELECT id, created_at, total_minor
FROM orders
WHERE customer_id = :customer_id
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;   -- skip (page - 1) * 20 = 40 matching rows, return the next 20

This maps naturally onto numbered pages, needs no state beyond two integers, and is perfectly adequate for small result sets, internal dashboards, and any screen where jumping straight to page 17 matters. Note that the ORDER BY already includes id as a tie-breaker: even offset pagination is only repeatable when the order is total.

The offset tax

The weakness is that an index does not make an arbitrary offset free. To return rows 100,001 to 100,020 the database still has to locate and step over the first 100,000 matching entries; PostgreSQL's documentation notes that rows skipped by OFFSET are still computed inside the server. The exact cost depends on the plan, filters, index, and engine, but it generally grows with the number of skipped rows. A keyset query, covered below, instead seeks to the anchor through the index and reads only the page, so the work it does is set by the page size. At scale, finding the rows often costs more than returning them.

400,000
20
Index entries visited per request versus rows skippedOffset pagination visits the skipped entries plus the page, so its line rises with the offset. A keyset query seeks to the anchor and visits only the page, so its line stays flat near zero at every depth.0250500750100002505007501000rows skipped (thousands)entries visited (thousands)
  • OFFSET n LIMIT size
  • keyset seek, LIMIT size
Index entries visited per request versus rows skipped. Counts the algorithm's work, not elapsed time; both cases also pay a short index descent of a few levels.
Offset visits
400,020
400,000 skipped + 20
Keyset visits
20
after the index seek
Work ratio
20,001x
offset / keyset
Rows returned
20
identical for both

The figure counts index entries visited, which is a property of the algorithm rather than a benchmark. Elapsed time follows it only roughly: caching, parallelism, and the engine's skip implementation all change the constant. As an illustration of the shape (one dataset, one engine, numbers not portable), a query that takes a couple of milliseconds on page 1 can take hundreds of milliseconds at page 1,000 and well over half a second at page 5,000, while the equivalent keyset query stays near its page-1 time at every depth. Measure on your own data with EXPLAIN (ANALYZE) before deciding that you have a depth problem at all.

Why Offset Pages Repeat and Skip Rows

Offset is a position counted from the top of a list that keeps changing; keyset is an anchor in the data

Take a newest-first order list at time t1: O9, O8, O7, O6, O5, O4, with three rows per page. The client fetches page 1 and receives O9, O8, O7. Before it asks for page 2, a new order O10 is created. At t2 the list is O10, O9, O8, O7, O6, O5, O4, and page 2 is OFFSET 3, which skips O10, O9, O8 and returns O7, O6, O5. The client now holds O7 twice. The new row pushed every existing row one position down, so "skip three" no longer means "skip what I already saw."

Deletions break the other way. If O9 is deleted after page 1, the list at t2 is O8, O7, O6, O5, O4; OFFSET 3 skips O8, O7, O6 and returns O5, O4. O6 was never delivered, and nothing in the response says so. Duplicates are at least visible to a client that de-duplicates by id; a silent skip is not recoverable by the client at all. Offset pagination gives no stable snapshot across independent requests, which matters for feeds, exports, reconciliation jobs, and any workflow that must process each record exactly once.

The figure below runs both strategies over the same data with real pagination logic: the offset window is a slice at (page - 1) × 3, and the keyset page is every row strictly after the last key the client saw. Use the buttons to move between "nothing changed", "a row was inserted", and "a row before the offset was deleted", and raise the page count to 3 to see the traversal complete.

Between request 1 and 2:
2 pages of 3
Offset and keyset pagination on two timelinesOrders are listed newest first, three per page. Request 1 reads O9, O8, O7 at time t1. Scenario "After insert (O10 arrives)" then changes the data before the next request. Offset pagination received: O9, O8, O7, O7, O6, O5 (1 repeat, 0 missed). Keyset pagination received: O9, O8, O7, O6, O5, O4 (clean).Offset: LIMIT 3 OFFSET (page - 1) x 3Request 1 reads data as of t1Request 2 reads data as of t2What the client receivedO9O8O7O6O5O4O10O9O8O7O6O5O4O9O8O7O7repeatO6O5Keyset: WHERE (created_at, id) < last seen keyRequest 1 reads data as of t1Request 2 reads data as of t2What the client receivedO9O8O7O6O5O4O10O9O8O7O6O5O4O9O8O7O6O5O4
  • Page 1
  • Page 2
  • Page 3
  • Returned once
  • Returned again (repeat)
  • Never returned (missed)
Offset and keyset pagination on two timelines. Orders are listed newest first, three per page. Request 1 reads O9, O8, O7 at time t1. Scenario "After insert (O10 arrives)" then changes the data before the next request. Offset pagination received: O9, O8, O7, O7, O6, O5 (1 repeat, 0 missed). Keyset pagination received: O9, O8, O7, O6, O5, O4 (clean).
Offset, last query
OFFSET 3
returned O7, O6, O5
Offset result
1 repeat, 0 missed
6 rows delivered
Keyset, last query
after (7, O7)
returned O6, O5, O4
Keyset result
clean
6 rows delivered

Read the keyset timeline carefully, because it shows what keyset does and does not promise. After the insert, page 2 asks for rows strictly after (7, O7) and returns O6, O5, O4; the new O10 sorts before the cursor, so this client never sees it. That is the correct outcome for a walk that started at t1: the new row belongs to the top of the list, and the client can see it by refreshing from the beginning. Keyset removes the shifting-window artefact; it does not make the client's view complete or up to date. The cases where keyset itself repeats or misses a row are covered in Changing Sort Keys, Snapshots, and Exports.

Keyset Pagination in SQL: LIMIT n+1, Tuple Comparison, and the Supporting Index

Replace "skip N rows" with "start after the last row I saw"

With a descending (created_at, id) order, the first page is an ordinary ordered query that asks for one more row than it will return:

First page: request 21, return 20
SELECT id, created_at, total_minor
FROM orders
WHERE customer_id = :customer_id
ORDER BY created_at DESC, id DESC
LIMIT 21;

If 21 rows come back there is a next page: return the first 20 and set has_more = true. The 21st row is discarded; it only answers 'is there more?' without a second COUNT query.

Suppose the last returned row has created_at = '2026-10-29T10:00:00Z' and id = 'ord_1042'. The next page asks for rows strictly after that key:

Next page: continue strictly after the last key
SELECT id, created_at, total_minor
FROM orders
WHERE customer_id = :customer_id
  AND (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 21;

The row-wise comparison (created_at, id) < (:c, :i) is lexicographic: it means created_at < :c OR (created_at = :c AND id < :i). It is valid here because both columns sort in the same direction, so the ordering the query asks for and the ordering the comparison tests are the same. The tie-breaker is not decoration. Orders are created in bursts, and many can share one timestamp, so created_at alone is not a unique position in the list.

Why the second sort key is mandatoryThree orders share the timestamp 10:00:00. Page 1 ends at ord_1042. Continuing with created_at < cursor skips ord_1041 and ord_1040; created_at <= cursor repeats ord_1042 and can never advance past the tie; the composite (created_at, id) comparison returns exactly the next rows.Rows ordered by created_at DESC, id DESC; three rows tie on the timestampsame created_at: only id separates these three rowsord_104310:00:01ord_104210:00:00ord_104110:00:00ord_104010:00:00ord_103909:59:59Request 1 (page size 2)cursor = (10:00:00, ord_1042)page 1page 1created_at < :c(timestamp only)MISSEDMISSEDreturnedSkips 1041, 1040with no errorcreated_at <= :c(timestamp only)repeatreturnedMISSEDStalls: page 3 equalspage 2, forever(created_at, id) < (:c, :id)(composite key)returnedreturnedEvery row exactly oncein 3 requestsVerdict: full traversal
  • Page 1
  • Returned (correct or not, see verdict)
  • Repeat
  • Should have been returned
Why the second sort key is mandatory. Three orders share the timestamp 10:00:00. Page 1 ends at ord_1042. Continuing with created_at < cursor skips ord_1041 and ord_1040; created_at <= cursor repeats ord_1042 and can never advance past the tie; the composite (created_at, id) comparison returns exactly the next rows.

The figure uses three orders created in the same second and a page size of two. Page 1 ends at ord_1042. Comparing on the timestamp alone with < excludes the whole tied group, so ord_1041 and ord_1040 are silently skipped. Using <= repairs the skip but re-delivers ord_1042, and when more rows tie than fit on a page the cursor never advances: page 3 equals page 2 forever. The composite comparison returns exactly ord_1041, ord_1040, then ord_1039. The same mistake shows up on the ordering side: if the query orders only by created_at, the database may order tied rows differently on successive calls, producing repeats and gaps even when nobody is writing. A deterministic, unique tie-breaker is mandatory for any sort key that can repeat.

When the tuple comparison is not valid

Row-value comparison is only a shorthand for same-direction keys. Not every engine supports it (SQL Server, for instance, has no row-value comparison), and even where it exists the optimizer's use of it for index range scans varies, so check the plan with EXPLAIN. Two common cases need the predicate written out explicitly.

Mixed directions. For ORDER BY created_at DESC, id ASC the tuple form would compare id in the wrong direction. The correct continuation expands to:

Mixed directions: created_at DESC, id ASC
WHERE customer_id = :customer_id
  AND (   created_at < :last_created_at
       OR (created_at = :last_created_at AND id > :last_id))
ORDER BY created_at DESC, id ASC
LIMIT 21;

Nullable sort keys. In SQL, NULL compared with anything is unknown, so a row whose key is null silently drops out of a predicate built from <. State where nulls sort, and handle them in the predicate. With NULLS LAST, continuing after a row with a real timestamp must still admit the null rows that follow it:

Nullable key: created_at DESC NULLS LAST, id DESC
-- cursor row has a non-null created_at
AND (   created_at < :last_created_at
     OR (created_at = :last_created_at AND id < :last_id)
     OR created_at IS NULL)

-- cursor row itself has created_at IS NULL
AND (created_at IS NULL AND id < :last_id)

The simpler fix is to make the sort key NOT NULL (or COALESCE it to a sentinel in both the index and the query) so that none of this is needed.

The supporting composite index

A keyset query is only cheap if the database can seek to the cursor and read forward. That needs an index whose leading columns are the equality filters, followed by the sort keys in query order:

Index that serves both the filter and the keyset order
CREATE INDEX idx_orders_customer_created_id
ON orders (customer_id, created_at DESC, id DESC);

The engine descends the B-tree to the first entry after the cursor within one customer and then reads a bounded number of entries, so the cost depends on the page size rather than on how far into the list the client is. In PostgreSQL a B-tree can also be scanned backwards, so declared directions matter mostly when they are mixed. Real performance still depends on selectivity, heap lookups for columns outside the index, and the plan the optimizer picks, so verify with EXPLAIN (ANALYZE) on realistic data rather than assuming.

Backward traversal

Keyset is natural for "next". "Previous" is the same query mirrored: flip the comparison, flip the order, and reverse the rows in the application. Starting from the first row of the current page:

Previous page from the first row on the current page
SELECT id, created_at, total_minor
FROM orders
WHERE customer_id = :customer_id
  AND (created_at, id) > (:first_created_at, :first_id)
ORDER BY created_at ASC, id ASC     -- reversed so LIMIT takes the rows nearest the cursor
LIMIT 21;
-- then: drop the 21st row, reverse the remaining 20 into display order

The extra row again answers whether more pages exist in that direction. An index declared (…, created_at DESC, id DESC) serves this by a backward scan.

Cursor Pagination Is an API Contract, Not a Separate SQL Algorithm

A cursor is an opaque token; keyset is one common way to implement it

A cursor is an opaque token that tells the server where to continue. Keyset pagination is the most common way to implement that token because the position is a real key in the data, but the two terms are not synonyms. Other cursor systems encode a position in a snapshot, a database-specific continuation token, or a handle to server-side state. "Cursor" describes the contract the client sees; "keyset" describes how the server turns the token into a query. Keeping them apart lets you change the implementation (say, from a single key to a composite key, or from live traversal to a snapshot) without breaking clients.

List response with an opaque cursor
{
  "data": [{ "id": "ord_1043" }, { "id": "ord_1042" }],
  "page": {
    "has_more": true,
    "next_cursor": "<opaque-token>"
  }
}

Equivalent designs put the next URL in an RFC 8288 Link header (rel="next"). Either way, clients follow the link or echo the token; they never build it.

A useful token carries a format version, the last sort key, enough context to recognise the query it belongs to, and an expiry, and is authenticated so it cannot be edited. Base64 is an encoding, not protection: anyone can decode and modify eyJpZCI6OTk5OX0=. Avoid putting sensitive values in a readable token, and sign it (an HMAC) or encrypt it as your threat model requires.

Signing and validating a cursor (illustrative TypeScript)
import { createHmac, timingSafeEqual } from 'node:crypto';

interface CursorPayload {
  v: 1;                    // format version, lets you change the key shape later
  k: [string, string];     // last created_at, last id
  f: string;               // hash of canonical filter + sort + tenant
  exp: number;             // expiry, epoch seconds
}

const sign = (body: string, key: Buffer) =>
  createHmac('sha256', key).update(body).digest('base64url');

export function encodeCursor(p: CursorPayload, key: Buffer): string {
  const body = Buffer.from(JSON.stringify(p)).toString('base64url');
  return body + '.' + sign(body, key);
}

export function decodeCursor(token: string, key: Buffer, expectedF: string, now: number): CursorPayload {
  const [body, sig] = token.split('.');
  const good = Buffer.from(sign(body ?? '', key));
  const given = Buffer.from(sig ?? '');
  if (good.length !== given.length || !timingSafeEqual(good, given)) throw new Error('invalid_cursor');
  const p = JSON.parse(Buffer.from(body, 'base64url').toString()) as CursorPayload;
  if (p.v !== 1 || p.exp < now) throw new Error('expired_cursor');
  if (p.f !== expectedF) throw new Error('cursor_does_not_match_query');
  return p;
}

Reject with a 400-class error. Include a key id if you rotate signing keys.

Signing only gives integrity. The decisive protection is binding: the cursor is valid only for the filter, sort order, and tenant it was issued for, and the server recomputes that fingerprint from the current request and compares it. Otherwise a client can take a cursor from ?status=paid and replay it with ?status=refunded&sort=total, where its key means nothing, or take a cursor issued to one tenant and use it for another. Binding is not authorization either: every query must still apply the authenticated caller's scope (WHERE customer_id = :caller) independently of whatever the token says. A request that fails validation should be an explicit error, not an empty page, so a client bug is not mistaken for "no more data".

The incident this prevents is mundane. A list API used base64({"id": 4231}) as its cursor and its query was WHERE id > :cursor_id with no owner filter; a client edited the token to a neighbouring id and read other users' rows. The encoding was never the defect. The query trusted the token instead of the authenticated caller. Signing makes tampering detectable and discourages probing, but it is an integrity measure, not an authorization control.

One cursor round trip: verify, bind, query, re-issueThe client sends an opaque cursor. The API has the cursor codec verify its signature, version and expiry, compares the filter, sort and tenant the cursor was bound to with the current request, runs a keyset query for one extra row, trims the page, and signs a fresh cursor from the last returned row.Clienttreats cursor as opaqueList endpointowns the contractCursor codecsign / verifyDatabasecomposite index1. GET /orders?limit=20&cursor=<token>2. verify signature, version, expiry3. key, filter hash, tenant4. hash(filter+sort+tenant) must match; else 4005. WHERE tenant AND (ts,id) < key LIMIT 216. 21 rows (or fewer)7. return 20; has_more = rows > 208. sign cursor from last returned row9. 200 {data, has_more, next_cursor}
One cursor round trip: verify, bind, query, re-issue. The client sends an opaque cursor. The API has the cursor codec verify its signature, version and expiry, compares the filter, sort and tenant the cursor was bound to with the current request, runs a keyset query for one extra row, trims the page, and signs a fresh cursor from the last returned row.

A client must not construct, parse, or compare cursors. Treat the token as a bookmark valid for the same logical query, and document an expiry (for example, minutes to days depending on how long clients legitimately pause) so you can retire old key formats.

Changing Sort Keys, Snapshots, and Exports

What keyset avoids, what it does not, and what an export actually needs

Keyset pagination avoids one specific failure: the shifting window caused by rows inserted before the cursor. It does not provide snapshot isolation across requests, and the guarantee is easy to overstate: a deterministic, tie-broken cursor stops inserts and deletes before the cursor from shifting later pages, but nothing more. If a row's sort key changes, the row can jump across the cursor boundary. Newest-first by updated_at, an unseen row that gets updated moves to the top, behind the cursor, and is never delivered. Oldest-first, an already-delivered row that gets updated moves to the end and is delivered a second time. Deletions and changed filters likewise alter what is visible mid-walk.

A changing sort key moves rows across the cursorKeyset pagination over updated_at, three rows per page. When row B is updated between requests, it jumps to the opposite end of the order. Newest-first, the unseen row B jumps above the cursor and is never delivered. Oldest-first, B was already delivered on page 1 and, after the update, is delivered again on page 3.ORDER BY updated_at DESC: unseen row B is updated, then missedRequest 1 reads data as of t1Request 2 reads data as of t2What the client receivedB: t=20 became t=70Ft=60Et=50Dt=40Ct=30Bt=20At=10Bt=70Ft=60Et=50Dt=40Ct=30At=10Ft=60Et=50Dt=40Ct=30BmissedAt=10ORDER BY updated_at ASC: already-seen row B is updated, then repeatedRequest 1 reads data as of t1Requests 2-3 read data as of t2What the client receivedB: t=20 became t=70At=10Bt=20Ct=30Dt=40Et=50Ft=60At=10Ct=30Dt=40Et=50Ft=60Bt=70At=10Bt=20Ct=30Dt=40Et=50Ft=60Brepeat
  • Page 1
  • Page 2
  • Page 3
  • Returned once
  • Returned again (repeat)
  • Never returned (missed)
A changing sort key moves rows across the cursor. Keyset pagination over updated_at, three rows per page. When row B is updated between requests, it jumps to the opposite end of the order. Newest-first, the unseen row B jumps above the cursor and is never delivered. Oldest-first, B was already delivered on page 1 and, after the update, is delivered again on page 3.

For a live feed this is often acceptable: the client sees a plausible, mostly-current list and refreshes from the top. The remedy for correctness-sensitive reads is to sort on a key that never changes once written (created_at and a unique id, or an append-only sequence) so that no row can cross the cursor.

For an export that must represent one consistent point in time, live traversal is not enough. Two techniques are common, and both have limits. A high-water mark captures the maximum immutable sequence number when the export starts and pages only through rows at or below it:

High-water-mark export (bounds new rows only)
-- once, at export start
SELECT max(sequence) AS hwm FROM ledger_entries;

-- every page
SELECT sequence, account_id, amount_minor
FROM ledger_entries
WHERE sequence > :last_sequence AND sequence <= :hwm
ORDER BY sequence
LIMIT 1000;

This bounds the set of new rows, but it does not freeze updates or deletions to older rows. It also has a subtle hole: if sequence values are assigned when a transaction inserts but become visible only when it commits, a transaction holding a sequence value below hwm can commit after you read the maximum, and its row would be missed. You must wait for in-flight writers to finish, or use a commit-ordered feed. The alternative is a real database snapshot (for instance a repeatable-read transaction, optionally shared with workers through an exported snapshot in PostgreSQL). That gives the strongest semantics, at the price of a long-lived transaction that holds a connection, can delay cleanup of old row versions, and cannot be carried across stateless HTTP requests without a server-side export job that owns it. Strong snapshot semantics need an appropriate consistency mechanism; pagination alone never supplies one.

Total Counts, Page Numbers, and Jumping Around

The UX features that quietly dictate the strategy

A total_count is not free. On a large filtered set, an exact count may require scanning every matching index entry (in an MVCC database such as PostgreSQL, even an index-only count must still check row visibility), and the number is stale as soon as it is returned. Decide per screen whether you need an exact count, an approximate one, or none. Infinite-scroll lists only need has_more, which the LIMIT n+1 trick provides for nearly nothing. When a count is wanted for display, a capped count is a good middle path:

Capped count: exact below the cap, '1000+' above it
SELECT count(*) FROM (
  SELECT 1 FROM orders
  WHERE customer_id = :customer_id
  LIMIT 1001
) capped;   -- 1001 means "more than 1000"

Planner estimates (for example from EXPLAIN or table statistics) are another option for 'about 1.2 million' style labels.

Offset pagination supports "jump to page 10" because page numbers are just arithmetic. Keyset pagination optimizes next and previous; reaching an arbitrary position without walking there requires resolving a number into a key, which the database cannot do without counting. Do not promise numbered pages on a keyset API unless you have designed how those positions are resolved. Practical options are to jump by a meaningful key instead (a date or an alphabetical prefix), to offer numbered pages for the first few pages only and switch to next/previous beyond them, or to keep a precomputed table of page-boundary keys for data that rarely changes.

Production Checks: Page Size, Sort Whitelists, and Index Fit

Pagination cannot compensate for an unbounded or unindexed query

Two mistakes recur. The first, sorting only by a non-unique column, was shown above. The second is letting the query shape escape the design. Allowing a client to send limit=1000000 turns a bounded response into the unbounded one pagination was meant to prevent, so set a default (say 20), enforce a maximum (say 100; both illustrative), and document whether you clamp or reject values above it. Clamping is friendlier; rejecting makes client bugs visible. Pair it with request limits from rate limiting, because one client paging aggressively through deep offsets consumes far more database time than its request count suggests.

The deeper mistake is index mismatch. An index on (customer_id, created_at DESC, id DESC) serves the order-history query perfectly and does nothing for a client that filters by status and sorts by price. The database will filter and sort a large intermediate set for every page, and the "fast" keyset query becomes a repeated sort. The fix is to support only the access patterns you have indexed, for example with a whitelist of sort fields (which also keeps arbitrary column names out of your ORDER BY), and to build one composite index per supported combination, equality filters first and then the sort keys with the unique tie-breaker last, such as (customer_id, status, price, id). Each index costs write throughput and storage, which is a good reason to keep the list of sortable fields short.

Three failures account for most pagination incidents in practice. A bulk export walks a table with OFFSET in steps of a few thousand; the first pages are fast, and by page 50,000 each request makes the database step over a million entries, so the job gets slower as it progresses and drags interactive traffic down with it. Cap the maximum offset on interactive endpoints, move deep traversal to keyset, and run exports as a separate job (ideally against a replica). A team adds ORDER BY popularity DESC to a listing whose column has no index, and every page request now sorts the whole filtered set; index the sort key as part of the same change. And an API that returns a total on every page runs a counting query on every page view, which is why has_more is the default and totals are opt-in.

One correction to a common rule of thumb: a random UUID is a perfectly good unique tie-breaker, because it only has to make the order total. It becomes a problem only when it is the sole sort key and users expect the list to be chronological; pair it with created_at, or use a time-ordered identifier such as UUIDv7 or ULID.

End to End: Paginating a Product Catalog

The same one-million-row resource, first with offset and then with a cursor

Take GET /products over about one million rows. A first version, built for a numbered-page UI, looks like this:

Offset version
GET /products?limit=20&offset=0
200 OK
{ "data": [ ...20 items ], "total": 1000000, "page": 1, "pages": 50000 }

GET /products?limit=20&offset=100000
200 OK     (illustrative: ~300 ms, about 100,020 index entries visited, 20 returned)

The total costs a counting query on every request, and the second call does about five thousand times the work of the first to return the same twenty rows.

Moving to a cursor changes the request and response, and changes where the work happens. The server orders by (created_at, id), asks for 21 rows, and returns a token for the last row it kept:

Cursor version
GET /products?limit=20
200 OK
{ "data": [ ...20 items ],
  "page": { "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjlUMTA6MDA6MDBaIiwicHJkXzQyMCJdfQ.q7..." } }

GET /products?limit=20&cursor=eyJ2IjoxLCJrIjpb...
200 OK     (illustrative: ~2 ms at any depth; a B-tree seek plus 21 entries)

GET /products?limit=20&cursor=<edited-token>
400 Bad Request   { "type": ".../invalid-cursor", "title": "Invalid cursor" }

Tokens are shown truncated. The part before the dot is the base64url payload (version, last created_at and id); the signature follows it.

Three decisions made this change safe. The cursor carries the full composite key, not just an id, so it keeps working when the sort is anything other than a unique monotonic column. The token is opaque and verifiable, so the server can later change what it encodes. And the total field disappeared on purpose: the UI switched to infinite scroll, which needs only has_more. If product management insists on a count, the capped count shown earlier ("1000+") gives them something honest at a bounded cost. The list is a GET, so a client that loses a response can safely retry with the same token; see Retry & Backoff for how to do that politely.

Choosing a Strategy: When to Use Offset, Keyset, or a Snapshot Job

Walk the four contract questions for the workload instead of choosing by habit

Start from consistency and navigation requirements, then let data size break ties. The flow below encodes the decisions in the order that matters: a point-in-time requirement overrides everything, a need for page numbers comes next, and only then does table size decide how long offset stays acceptable.

Choosing a pagination strategyStart by asking whether the result must be a point-in-time snapshot; if so, build a snapshot export job. Otherwise ask whether users need numbered pages. If they do and the table is small or the depth is capped, use offset with a total order; if the table is large and depth cannot be capped, jump by key or offer only next/previous. If numbered pages are not needed, use keyset behind an opaque cursor with a composite index.Must the set be apoint-in-time snapshot?Snapshot export jobDB snapshot or safe high-water markNeed numbered pagesor jump to page N?Small table, ordepth can be capped?Offsetwith a total orderJump by key, ornext/prev onlyKeyset behind opaque cursorcomposite index, has_moreyesnoyesnoyesno
Choosing a pagination strategy. Consistency requirements come first, navigation needs second, table size last.
WorkloadStrategyWhyWatch for
Customer order history, feeds, timelinesCursor over keyset (created_at, id)The user scrolls forward, new rows arrive often, no exact page number is needed, and the sort key never changes after write.Composite index, signed and bound cursor, has_more instead of a total.
Admin table of 300 rows with numbered pagesOffsetAt page size 25 that is 12 pages and a worst-case OFFSET 275; skipping a few hundred rows is trivial and page jumps are wanted.Still use a total order (ORDER BY name, id); revisit if the table grows by orders of magnitude.
Search results with page numbers over a large indexOffset with a depth cap, or keyset with jump-by-keyUsers rarely go past the first few pages, so a maximum offset bounds the cost without hurting them.Reject or clamp deep requests explicitly; do not silently return a different page.
Append-only events or time seriesKeyset on the timestamp plus a unique idThe natural sort key already exists and is immutable, so WHERE (created_at, id) < cursor uses the index directly.Timestamp ties, and clock skew between writers if ids are not commit-ordered.
Bulk processing or reconciliationKeyset batches over an immutable key, inside a jobProcess sorted chunks with WHERE id > :last_id ORDER BY id LIMIT 1000; each chunk costs about the same.Rows changing while the job runs; checkpoint the last key so it can resume.
Financial export requiring consistent membershipSnapshot-based export jobNeither basic offset nor live keyset traversal can promise one point-in-time set; rows change while it runs.Database snapshot or a commit-safe high-water mark, an owning job, resumability, and checksums.

Compared directly, the two main approaches trade in opposite directions. Offset gives you numbered pages, a trivial implementation, and stateless URLs a human can edit, and costs you depth-dependent work and shifting windows under writes. Keyset gives you work proportional to the page size, a stable walk when rows arrive before the cursor, and cheap has_more, and costs you arbitrary page jumps, a unique ordered key, a matching composite index per supported sort, and a more careful implementation (mixed directions, nulls, backward traversal). Both inherit the same blind spot: neither is a snapshot. The scale at which offset stops being acceptable is a measured property of your table and queries, not a universal number, which is why "ten million rows" is a hint and not a rule.

The pattern across the workloads: keyset improves deep-page efficiency and avoids offset shifting, but it does not create a frozen snapshot of changing data. The right strategy follows from the access pattern and the consistency requirement, not from which one is more fashionable.

Where to Go Next

Pagination sits between the HTTP contract and the load you place on the database

Where the limit and cursor parameters, Link headers, and error responses belong in a resource-oriented API is the subject of REST, and the conventions that keep list endpoints consistent across a whole API are in API Design. A client that pages aggressively can cost far more database time than its request count suggests, so pair deep or bulk traversal with rate limiting, and make resumed walks polite with retry and backoff. Writes that accompany a paginated workflow, such as an export that creates a job, are the territory of Idempotency.