Skip to content

Paginate with keyset (seek) instead of OFFSET snippet

OFFSET pagination walks past N rows on every page — page 1000 re-reads 999 pages of data, and any insert between requests shifts rows so items repeat or vanish.

OFFSET pagination walks past N rows on every page — page 1000 re-reads 999 pages of data, and any insert between requests shifts rows so items repeat or vanish. Keyset (seek) pagination instead asks: give me the rows strictly after the last one I saw. The sort key must be unique and stable — a timestamp alone is not, tie rows reorder unpredictably — so the id rides along as the tiebreaker, and the composite (created_at, id) index serves the scan. The row comparison syntax (a, b) > (x, y) is the concise Postgres spelling.

Runnable recipe · 1 languages
Files & Datasqlpaginationkeysetseekperformance

Every language

1 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

SQLSQLrunnable
-- page 1
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- next page: feed the LAST row's keys back in
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ('2026-08-24 18:02:00+00', 9014)
ORDER BY created_at DESC, id DESC
LIMIT 20;

The tuple comparison (created_at, id) < (last_seen_at, last_seen_id) is lexicographic — exactly the ORDER BY in reverse. Needs the composite index CREATE INDEX ON posts (created_at DESC, id DESC) to stay an index scan at depth. Run both queries in the playground.

Run in the SQL playground →

Keep going

Read the sql-indexing cheatsheet →