Skip to content

OFFSET ではなくキーセット(seek)でページネーションする snippet

OFFSET ページネーションは毎ページ N 行を読み飛ばします。1000 ページ目は 999 ページ分のデータを読み直し、リクエスト間に挿入があると行がずれてアイテムが重複したり消えたりします。キーセット(seek)ページネーションは代わりに、自分が最後に見た行より厳密に後の行を要求します。ソートキーは一意で安定でなければなりません。タイムスタンプ単独ではそうではなく、同点の行は予測不能に並び替わるため、id がタイブレーカーとして同行し、複合 (created_at, id) インデックスがスキャンを支えます。行比較の構文 (a, b) > (x, y) は Postgres 流の簡潔な綴りです。

OFFSET ページネーションは毎ページ N 行を読み飛ばします。1000 ページ目は 999 ページ分のデータを読み直し、リクエスト間に挿入があると行がずれてアイテムが重複したり消えたりします。キーセット(seek)ページネーションは代わりに、自分が最後に見た行より厳密に後の行を要求します。ソートキーは一意で安定でなければなりません。タイムスタンプ単独ではそうではなく、同点の行は予測不能に並び替わるため、id がタイブレーカーとして同行し、複合 (created_at, id) インデックスがスキャンを支えます。行比較の構文 (a, b) > (x, y) は Postgres 流の簡潔な綴りです。

Runnable recipe · 1 languages
Files & Datasqlpaginationkeysetseekperformance

Every language

1 languages, 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 →