プロトコル

ページネーション設計とは?オフセットとカーソルの選び分けと実装を解説

一覧を返すAPIで、1回のレスポンスに何件を載せ、その次の何件をどう指定させるか。この取り決めがページネーション設計です。件数で位置を指すオフセット方式は実装が短く済む一方、深いページで走査コストが線形に膨らみ、取得中にデータが増減すると同じ行が二度出たり抜けたりします。扱う範囲は、方式ごとの構造差、キーセット述語とページトークンの書き方、PostgreSQLとMySQLで実装を変えざるをえない理由、管理画面を据え置く条件までです。RESTの6原則と統一インターフェースそのものは別記事に譲り、一覧リソースの分割返却だけに絞ります。

まとめ|オフセットで足りる境界とカーソルへ切り替える条件

オフセット方式が買っているものは2つだけです。総ページ数を画面に出せること、任意のページ番号へ直接飛べること。対価は、OFFSET N が N 行を読んで捨てるためレイテンシがページ深度に比例し、取得の合間に行が増減すると重複と欠落が静かに混ざる点。カーソル方式はこれを解消する代わり、任意ジャンプと総ページ数を捨てます。

線引きは単純です。絞り込み後の母数が数万件以下、ページ番号での直接ジャンプが業務手順に組み込まれている、一覧を開いている間の更新頻度が低い。この3つがそろう管理画面なら、オフセットのまま据え置いて構いません。時系列フィード、無限スクロールのUI、外部公開する一覧API、母数が数十万件超のテーブルは、1つでも当てはまればカーソルへ移します。ページサイズは既定20・上限100とし超過は丸める、総件数は既定で返さず has_more だけを返す。この2点を先に固めれば、後からの作り直しを避けられます。

ページネーション設計とは|一覧APIの分割返却を決める3つの設計要素

用語と決めるべき要素を固めます。

分割返却が必要になる境界|1リクエストの上限件数を決める判断材料

判断材料は3つあります。1件あたりのJSONバイト数に件数を掛けたレスポンスサイズ、データベースが実際に読む行数、クライアント側の描画コストです。1件1〜3KBのリソースなら100件で数百KBに達し、モバイル回線では体感が崩れます。実在するAPIも1リクエスト100件前後を上限に置いており、既定20・上限100から始めて実測で調整する形が扱いやすいでしょう。

3つの方式の呼び名|オフセット・ページ番号・カーソルの指す範囲

方式は3つの名前で呼ばれますが、構造としては2種類しかありません。

  • オフセット指定:?offset=200&limit=20 のように、先頭から数えた件数で位置を指す
  • ページ番号指定:?page=11&per_page=20 のように番号で指す。内部では offset を (page – 1) × per_page で計算するため、オフセット指定の別表現にすぎない
  • カーソル指定:?limit=20&after= に続けて直前ページ末尾を表すトークンを渡す。キーセットページネーション、シークページネーションとも呼ばれる

分かれ目は「位置を件数で指すか、直前の行が持つ値で指すか」の1点です。この違いが性能とデータ整合の差を生みます。

レスポンス側の3形式|Linkヘッダ・本文メタ・GraphQL接続の使い分け

次ページの伝え方にも流派があります。GitHubはRFC 8288のLinkヘッダに prev・next・first・last を載せる方式。先頭ページに prev は付かず、最終ページが計算できないときは last も落ちます。

Stripeは本文側に真偽値の has_more を置き、応答を objectdatahas_moreurl の4要素で統一しました。JSON:API v1.1 は links オブジェクトに first・last・prev・next を置き、不在なら省略かnullと定め、page クエリパラメータファミリを予約しています。

GraphQLでは Cursor Connections Specification が標準です。Edge型は node と cursor が必須で、node はリストを返せません。PageInfo は hasNextPage・hasPreviousPage・startCursor・endCursor の4つ、引数は前方が first と after、後方が last と before。この接続型の制約は実装コストへ直結するので、REST APIとGraphQLの使い分けの検討段階で見ておいてください。

オフセット方式の限界|深いページで起きる性能劣化と重複・欠落の仕組み

限界は性能と整合の2方向に出ます。

OFFSET句が読み捨てる行数|100万件目の取得で生じるコスト

OFFSET N の意味は「N行を読んでから捨てる」です。OFFSET 1000000 LIMIT 20 なら、返す20行のために1,000,020行を走査します。インデックスだけで完結するカバリング構成にしても、走査量そのものは減りません。

結果としてレイテンシがページ深度に正比例します。1ページ目が5msで返るテーブルでも、500ページ目では数百msに膨らむ。実装の粗さではなくSQLの意味論から来る性質で、ページ番号UIの「最後のページへ」ボタンが最も重い現象もここから生まれます。

ウィンドウずれによる重複と欠落|行の挿入・削除が起きた際の実害

整合の問題はさらに厄介です。page=1 を取得した直後に先頭へ1件挿入されると、全行が1つ後ろへずれます。page=2 の先頭は、page=1 の末尾で既に見た行です。逆に1件削除されれば、1行が誰にも読まれないまま飛ばされます。

実害が出るのは画面よりバッチです。一覧APIを全ページ回して集計や外部連携を行う処理で、取りこぼしが例外を出さずに混ざる。ログにも残りません。Elasticsearchの公式もタイブレーカー欠如の帰結を「miss or duplicate hits」と表現しており、オフセット方式にそのまま当てはまります。

深いページで止まる10000件の壁|max_result_windowの既定値

検索エンジン側には明示的な上限があります。Elasticsearchは fromsize の合計を index.max_result_window で制御し、既定値は10,000。これを超えるリクエストは失敗します。各シャードが要求ヒットと前ページ分をメモリへ載せる実装のため、上限を引き上げれば劣化はメモリ圧迫として跳ね返ります。

公式は deep pagination への scroll API 利用を「もはや推奨しない」と明記し、search_after と Point in Time の組み合わせへ誘導しました。scroll が残っているのはバッチ処理向けであって、リアルタイムのページ送り用ではありません。深いページを高速に叩き続けるクライアントはレートリミットの429設計でも抑える対象になります。

カーソル方式の実装|キーセット述語とトークン設計でページを固定する手順

ここからは実装の中身に入ります。

キーセット述語の書き方|ソートキーと一意キーを組んだ複合条件

カーソル方式の本体は、WHERE句に置く比較述語です。ORDER BY created_at DESC, id DESC で並べる一覧なら、次ページの条件は「直前ページ末尾の (created_at, id) より小さい行」になります。PostgreSQLなら WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20 と書けます。

前提として、ソート順と同じ並びの複合インデックスが要ります。created_at DESC, id DESC の順で張っていなければ、述語を書き換えてもソート用の追加処理が残り、深いページは速くなりません。列順は必ず一致させてください。

タイブレーカーが要る理由|同一timestampで起きる取りこぼし

ソートキー単独のカーソルは壊れます。created_at が秒精度で、同一秒に30件が入っているとしましょう。20件目でページが切れたとき、WHERE created_at < $1 だけでは同一秒の残り10件がまとめて飛びます。

対策は一意キーを最後段に足すこと。主キーの連番でも、時刻順が保証されるULIDでも構いません。要件は「同値が発生しない列であること」だけです。Elasticsearchが Point in Time 併用時に _shard_doc を自動付与するのは、裏を返せば明示しない限り取りこぼしが起きうるという警告でもあります。

トークンの不透明化と失効|AIP-158が定める約3日の期限目安

カーソルの値をそのままクエリ文字列へ出すかは、セキュリティの判断を含みます。Google AIP-158 は page_token を「不透明(ただしURL安全)な文字列でなければならない」と規定し、Base64エンコードだけでは不十分と明記しました。

生の (created_at, id) を晒すと、値を書き換えて本来返すべきでない範囲を読み出す余地が生まれます。実装は、ソート条件とフィルタ条件のハッシュを含めてHMAC署名し、サーバ側で検証する形が扱いやすい。AIP-158 はページトークン以外のパラメータが変更された場合の INVALID_ARGUMENT を推奨しており、この検証がその手段になります。有効期限は、トークン状態をサーバ内部に保存する方式なら合理的な期間で失効させてよく、ガイドラインは約3日を挙げつつ明記は不要としています。

StripeとGitHubの応答形式|has_moreとLinkヘッダの差

終端の伝え方は3系統に分かれ、混在させるとクライアント側が分岐だらけになります。

実装例 次ページ指定 終端の伝え方 件数指定
Stripe v1 starting_after has_moreがfalse limit 1〜100・既定10
GitHub REST page または after Linkにnextが無い per_page 最大100
Google AIP-158 page_token next_page_tokenが空 page_size 上限へ丸め
GraphQL接続 after hasNextPageがfalse first

AIP-158 の next_page_token は空文字列が「終端を伝える唯一の手段」で、終端を判定できない場合でもフィールド自体を返す義務があります。社内で複数のAPIを作るなら3系統のどれか1つに統一し、APIゲートウェイの管理機能側でページサイズ上限を強制すると、実装者ごとのぶれが減ります。

実装差の吸収|PostgreSQL・MySQL・Elasticsearchの書き分け

同じ述語が、製品によって同じ性能を出しません。

PostgreSQLの行コンストラクタ比較|辞書順とNULLの扱い

PostgreSQLは行コンストラクタ比較を辞書順で評価します。(a, b) < (x, y) は左から順に比べ、最初に不等またはNULLのペアが見つかった時点で停止する。公式ドキュメントの例では ROW(1,2,NULL) < ROW(1,3,0) が true です。第2要素の 2 < 3 で決着するため、第3要素のNULLは評価されません。

落とし穴はNULL許容列です。比較するペアのいずれかがNULLだと結果はNULLになり、その行は WHERE 句を通りません。ソートキーにNULL許容の日時列を選ぶと、NULLの行だけが一覧から永久に消えます。IS DISTINCT FROM による回避策はインデックスの範囲スキャンを崩すため採りません。ソートキーは列定義の段階で NOT NULL にする。これが唯一の正解です。

MySQLで行比較を展開する理由|Bug#111952が示す実測差

MySQLでは同じ書き方が通用しません。行コンストラクタの不等号比較は範囲スキャンに落ちず、フルインデックススキャンになります。

MySQL Bug#111952(2023年8月2日報告・Verified)が実測値を示しています。主キー (p, i) に対する (p, i) > (25001, 50) では Handler_read_next が 2,500,149 回。同じ条件を (p = 25001 AND i > 50) OR p > 25001 と展開すると 99 回。約25,000倍の差です。影響バージョンには 5.7.43 だけでなく 8.0.34、8.1.0、8.4系までが挙がっており、2026年8月時点でも展開形で書くのが前提です。MySQL公式の Row Constructor Expression Optimization が対象としているのは IN() 述語であって、不等号比較は含まれていません。ORMが行コンストラクタを生成する場合は、実行計画を確認して生SQLへ落としてください。

search_afterとPITの併用|tie-breakerの自動付与

Elasticsearchでは search_after が対応する仕組みです。制約は2つ。全リクエストで sort 条件を完全に一致させること、タイブレーカー列を含めること。欠けば取りこぼしと重複が起きると公式が明言しています。

10,000件を超えて辿るなら Point in Time を併用します。PITはインデックス状態を固定し、リクエスト間にリフレッシュが挟まっても結果を揺らさない仕組みで、各検索で keep_alive を更新しながら維持する形。併用時は _shard_doc がタイブレーカーとして自動追加されます。ソートと範囲条件の並び順が走査量を決める構造は、MongoDBの複合インデックス設計と共通です。

方式の選定基準|管理画面で据え置く条件と無限スクロールで切り替える線引き

ここは条件を付けて言い切ります。

管理画面でオフセットを残す条件|10万件と直接ジャンプの要否

既存の管理画面をカーソル化する提案は、たいてい割に合いません。次の3条件がそろうなら、オフセットのまま据え置いてください。

  1. 絞り込み後の母数が概ね数万件以下で、10万件超がほぼ発生しない
  2. ページ番号を指定して直接飛ぶ操作が業務手順書に組み込まれている
  3. 一覧を開いてから閉じるまでに、対象データが更新される頻度が低い

3つそろえば、深いページの遅さは実際には踏まれず、ウィンドウずれも顕在化しません。書き換えにはインデックス追加、SQL修正、ページャ改修、回帰テストが付いてきます。踏まない問題のために払う費用ではない、という判断です。母数が数十万件を超える、あるいは更新が秒単位で入る一覧なら、1つ当てはまった時点で切り替えます。

無限スクロールでカーソルを必須にする理由|二重表示と欠落の防止

無限スクロールについては、条件なしでカーソル必須と結論します。

理由は不具合の見え方にあります。ページ番号UIは画面を切り替えるため、重複が起きても利用者はふつう気づきません。無限スクロールは前ページを保持したまま追記するので、ウィンドウずれが「同じ行が画面上に2度並ぶ」という可視の不具合になります。新着が入りやすい時系列フィードほど発生率も高い。読み込み済みの最終行の値をそのままカーソルへ渡せるぶん、クライアント側のコードは短くなります。

カーソル方式を採用しない3類型|件数固定・全件走査・帳票出力

反対に、カーソル化してはいけない類型が3つあります。

  • 件数が構造的に固定されたマスタ(都道府県47件、勘定科目、権限ロール)。ページネーション自体を置かず全件返す
  • 全件走査のバッチ。ページを辿らず、PITやトランザクション、CDCでスナップショットを取る。カーソルは途中で条件が変わらないことまでは保証しない
  • 帳票やCSVの出力。ページ分割は合計値と明細の整合を壊すため、ストリーミングで一気に吐く

失敗パターンとして多いのが、「将来に備えて一覧APIを全部カーソル化する」という横断的な決定です。管理画面でページ番号UIが作れなくなり、結局サーバにオフセット相当を再実装して二重管理を抱え込みます。

総件数と移行の実務|count省略の判断とオフセット併存期間の設計

最後に、運用に入ってから効いてくる2つの論点を扱います。

総件数を返さない判断基準|COUNT(*)が支配するレイテンシ

COUNT は条件に一致する行を実際に数えます。絞り込みが緩い一覧では、20件を返す本体クエリより総件数の集計のほうが遅い、という逆転がふつうに起きます。

AIP-158 は total_size を任意フィールドとし、推定値ならその旨を明記するよう推奨しています。分かれ目は用途です。画面に「全◯件」と出すだけなら、1,000件を超えたら「1000+」と表示する概算で足ります。厳密な値が要るのは、会計や在庫のように別システムと突き合わせる場面だけ。LIMIT に1を足して取得し、超過の有無で has_more を判定すれば、COUNTを走らせずに次ページの有無を返せます。

offsetとcursorを併存させる移行期|二重提供の打ち切り基準

既存APIをカーソルへ移すときは、いきなり切り替えません。after を追加し offset も当面残したうえで、どちらで呼ばれてもレスポンスに次カーソルを必ず入れ、クライアントの移行を促します。

打ち切りの基準は感覚ではなく数字で決めます。アクセスログで offset 指定のリクエスト比率を計測し、数パーセントを切った時点で Deprecation ヘッダを付け、期日を切って削除する。期日の切り方と告知の粒度をAPIバージョニングの運用設計と同じ枠組みで扱えば、パラメータ単位の廃止と版全体の廃止で運用がぶれません。

受託開発でページ送りを仕様化する手順|合意事項と見積りへの影響

発注・受注のどちらの立場でも、決めておくべき項目は4つです。既定ページサイズと上限件数、ソート順を変更できるか、総件数の要否、カーソル失効時の挙動。最後の1つは見落とされがちで、410を返すのか黙って先頭へ戻すのかでクライアントの実装が変わります。

見積りへの影響も見えにくい。カーソル化は述語の書き換えで終わらず、複合インデックスの追加、トークンの署名と検証、クライアント側の状態管理まで含みます。既存の管理画面があればページャUIの作り替えも発生するため、要件定義で4項目を確定させておくと後半の手戻りが減るでしょう。設計から実装までの相談はAPI開発・システム連携で受け付けています。

よくある質問

設計時によく挙がる疑問を5つ整理します。

ページネーションとページングの違いは何ですか?

実務上は同義です。英語圏のAPIドキュメントでは pagination が優勢で、paging はOSのメモリ管理文脈でも使われるため、API設計の話題では pagination のほうが誤解を招きません。日本語では画面のUI部品を「ページャ」、サーバ側の分割返却を「ページネーション」と呼び分ける現場もありますが、仕様書で区別する実益は乏しい。既定ページサイズと終端の伝え方を明記するほうが効きます。

ページサイズの上限は何件にすべきですか?

既定20・上限100が扱いやすい水準です。Stripeは limit が1〜100で既定10、GitHub REST APIは per_page の最大が100。上限超過はエラーを返すより丸めるほうが親切で、AIP-158 も page_size が上限を超えた場合は最大値へ強制すると規定しました。ただし負の値は INVALID_ARGUMENT でエラーにします。引き上げ要望が出たら、件数ではなくレスポンスバイト数で上限を決め直してください。

カーソルに主キーのIDをそのまま使ってよいですか?

内部向けAPIなら許容範囲ですが、外部公開では避けます。連番の主キーを露出させると総レコード数が推測でき、書き換えで範囲外を読み出す試行も容易になるためです。AIP-158 はページトークンを不透明な文字列とするよう求め、Base64エンコードだけでは不十分と明記しました。ソート条件とフィルタ条件を含む構造体をHMAC署名してURL安全な形式へ変換し、復号時に条件が一致しなければ INVALID_ARGUMENT を返します。

総件数を返さないとUIが作れないのでは?

has_more と「次へ」ボタンだけで成立するUIは多く、無限スクロールに至っては総件数を使いません。総ページ数が要るのはページ番号ボタンを並べる場合だけです。LIMIT に1を足して取得し、超過の有無で次ページの存在を判定すれば足ります。件数表示を残すなら、1,000件を超えたら「1000+」と丸める概算にする。COUNTを走らせずに済み、絞り込みが緩いときの応答時間が安定します。

無限スクロールのページはSEO上どう扱えばよいですか?

rel=next と rel=prev は、2019年3月の告知でインデックス用シグナルとして使われていないことが明言されました。現行の案内は、各ページに固有URLを与え、1ページ目を全ページのcanonicalにせず各ページ自身をcanonicalとし、次ページへは通常のアンカーでリンクするという内容です。無限スクロールだけではクローラが後続へ到達できないため、番号付きのページURLを併設し、アンカーでたどれる状態にしておきます。

関連記事

資料請求

RELATED POSTS 関連記事