sql 45 lines · 4 tabs

Cursor-based pagination with stable ordering

Shared by codesnips Jan 2026
4 tabs
CREATE TABLE posts (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    author_id    bigint NOT NULL REFERENCES authors (id),
    title        text   NOT NULL,
    body         text   NOT NULL,
    published_at timestamptz NOT NULL DEFAULT now()
);

-- Matches the ORDER BY exactly so pages are a single index range scan.
-- id is the deterministic tie-breaker for rows sharing published_at.
CREATE INDEX idx_posts_feed
    ON posts (published_at DESC, id DESC);
4 files · sql Explain with highlit

Keyset pagination (often called cursor-based pagination) replaces the classic LIMIT/OFFSET approach with a WHERE clause that seeks directly to the row after the last one seen. This matters because OFFSET n forces Postgres to scan and discard n rows on every page, so deep pages get linearly slower and inserts can shift the offset window and cause skipped or duplicated rows. Keyset pagination instead remembers the sort key of the last row and asks for everything strictly after it, giving stable, index-backed page fetches regardless of depth.

The posts schema tab defines the table and, crucially, a composite index on (published_at DESC, id DESC). The id column is included as a deterministic tie-breaker: published_at is not unique, so two posts sharing a timestamp would otherwise have an ambiguous order and a cursor could land mid-tie and drop rows. Ordering by (published_at, id) makes the total order strict, and the index lets Postgres satisfy both the filter and the sort with a single range scan.

The first page query tab shows the initial fetch: it simply orders by the composite key and takes LIMIT 21 when the page size is 20. Fetching one extra row is a cheap way to detect whether a next page exists without a separate COUNT. The next page query tab is the heart of the pattern — the row-value comparison (published_at, id) < ($1, $2) expresses "everything strictly before this cursor" in one clause that Postgres can push into the index. Using the tuple comparison rather than hand-writing published_at < $1 OR (published_at = $1 AND id < $2) is both clearer and lets the planner do an efficient index range scan.

The cursor encoding tab wraps this in a callable function that decodes an opaque cursor into its two components, so the API surface exposes a single string token rather than leaking raw column values. Encoding the cursor (for example base64 of published_at|id) keeps clients from crafting arbitrary filters and lets the schema evolve.

The main trade-off is that keyset pagination only supports next/previous navigation, not jumping to an arbitrary page number, and the cursor columns must exactly match the ORDER BY. When those constraints are acceptable — infinite scroll, feeds, large exports — this approach stays fast at any depth and never skips rows during concurrent inserts.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-based pagination with stable ordering — share card
Link copied