
ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け
一覧を「次へ」で分割表示するページネーション。素朴に LIMIT/OFFSET で実装すると、データが増えた途端に遅くなり、一覧が動くと行が重複・欠落します。この記事では、2大方式(オフセットとカーソル/キーセット)の違いと使い分けを、PostgreSQL 公式・Use The Index, Luke・Stripe・Slack を一次ソースに整理します。
2つの方式
- オフセット方式:
LIMIT n OFFSET m(=page番号)。「先頭から m 件飛ばして n 件」 - カーソル/キーセット方式: 前ページの最後の行の値を基準に「その続き」を取る
オフセット方式
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;- 利点: 任意ページへジャンプできる、総件数・総ページ数を出せる、実装が簡単
- 欠点その1(性能): PostgreSQL 公式いわく「OFFSET でスキップした行もサーバー内で計算されるため、大きな OFFSET は非効率」。
OFFSET 100000は10万行を処理してから捨てます - 欠点その2(ページずれ): ページ送りの最中に挿入・削除が起きると、行が重複したり欠落します(page drift)
WARNING
ORDER BY が一意でない LIMIT/OFFSET は危険です。PostgreSQL 公式も「順序を一意に固定しないと、毎回予測不能な部分集合が返る」と警告しています。必ず id などでタイブレークを入れます。
カーソル(キーセット)方式
前ページの最後の行のキー(例: created_at, id)を使い、WHERE で「まだ見ていない範囲」だけを取る方式です。
-- 複合インデックスが前提
CREATE INDEX idx_posts_created_id ON posts (created_at DESC, id DESC);
-- 前ページ末尾の値 (created_at, id) を受け取って続きを取得
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ('2024-01-15', 42)
ORDER BY created_at DESC, id DESC
LIMIT 10;- 利点: 大規模でも一定の速さ(インデックスで開始位置へ直行)、挿入・削除に強く安定
- 欠点: 任意ページへ飛べない(前後のみ)、総ページ数を出しにくい、一意なタイブレークが必須
- インデックスが要:
ORDER BYの列=インデックスの列にする。仕組みはデータベースインデックス入門を参照
(created_at, id) の行値コンストラクタ比較で複合インデックスを効かせます(PostgreSQL は最適化可。MySQL は最適化に制限がある場合があり要確認)。
比較表
| 観点 | オフセット | カーソル(キーセット) |
|---|---|---|
| 深いページの性能 | 悪化(スキップ分を走査) | 一定(インデックスで直行) |
| ページずれ | 起きる | 起きにくい |
| 任意ページジャンプ | 可能 | 不可(前後のみ) |
| 総ページ数 | 出せる | 出しにくい |
| 実装難易度 | 簡単 | やや複雑 |
| 向く用途 | 管理画面・検索結果 | 無限スクロール・大規模・更新が多い |
カーソルと API 設計
カーソルは不透明(opaque)に保つのが定石です。キー値を Base64 等でエンコードし、クライアントには中身を解析させません(内部実装を変えやすく、IDの露出も防げる)。Stripe・Slack・GitHub も同方針です。
GET /v1/customers?limit=10&starting_after=cus_xyz{ "has_more": true, "data": [ { "id": "cus_xyz" } ] }{ "ok": true, "messages": [], "response_metadata": { "next_cursor": "dGVhbTpDMDYx" } }GraphQL では Relay Cursor Connections(edges { node, cursor } と pageInfo { hasNextPage, endCursor }、first+after)が事実上の標準です。
NOTE
総件数の SELECT COUNT(*) は大規模テーブルで重いので、カーソル方式では「次があるか(has_more)」だけ返す(= LIMIT n+1 で1件多めに取り、あふれたら次ありと判断)と軽く済みます。
使い分け
- 管理画面で任意ページに飛びたい・件数を見せたい → オフセット(データ規模が中程度まで)
- 無限スクロール・APIの一覧・大規模・更新が多い → カーソル(キーセット)
- 併用も可(一覧はカーソル、管理画面だけオフセット 等)
よくある落とし穴
ORDER BYが一意でない: 同値の並びが不定になり重複・欠落。idでタイブレーク- OFFSET の性能劣化を見落とす: 開発時は小規模で速く、本番で増えてから露見
- 毎回
COUNT(*): 重い。has_more方式に - カーソルにソートと違う列: 正しい位置へ飛べない。カーソル=ORDER BY の列に揃える
- カーソルをクライアントで解析・改ざん: 不透明前提で設計する
まとめ
- オフセットは任意ジャンプ・総件数が強み、ただし深いページで遅く・ページずれ
- カーソル(キーセット)は大規模でも高速・安定、ただし任意ジャンプ・総ページ数が苦手
- キーセットは
(sort_col, id)の複合インデックス+一意タイブレークが肝 - カーソルは不透明に。API は
next_cursor/has_more(GraphQL は Relay Connections) - 管理画面=オフセット、API/無限スクロール=カーソルが基本線
「とりあえず OFFSET」で始めても、規模が増えると性能とページずれで詰みがちです。一覧APIは最初からキーセットを選んでおくと、後が楽になります。


