あらゆるリストAPIは、最終的に同じ課題に直面します。200万件の注文を、クライアントが順番に閲覧できる形でどう分割するかです。オフセットページネーションなら、シンプルなSQLとわかりやすいページ番号を使えます。カーソルベースなら、安定した結果とページの深さに依存しないレイテンシーを得られますが、「47ページへ移動」のような機能は失われます。
多くのチームは、チュートリアルで一般的なオフセットを選びます。しかし、注文テーブルが数百万行に達すると、4,000ページ目でタイムアウトが発生し、スクロール中に同じレコードが重複表示される問題も起こります。
この記事では、2つの方式の仕組み、オフセットが破綻する理由、StripeやSlackがカーソルを採用する背景、そしてApidogでチェーンリクエストを使って両方をテストする方法を解説します。
全体像を先に確認したい場合は、APIページネーションガイドも参照してください。
オフセットページネーションの仕組み
オフセットページネーションは、SQLのLIMITとOFFSETに直接対応します。クライアントはページ番号とページサイズを送信し、サーバーがSQLに変換します。
SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
このクエリは、1ページ25行の注文リストで3ページ目を返します。
GET /v1/orders?page=3&per_page=25
典型的なレスポンスは次のとおりです。
{
"data": [
{
"id": "ord_8821",
"customer_id": "cus_1932",
"total_cents": 4599,
"created_at": "2026-08-30T14:22:07Z"
}
],
"page": 3,
"per_page": 25,
"total": 1848203,
"total_pages": 73929
}
利点は明確です。
- 任意のページへ移動できる
- 合計件数や総ページ数を返しやすい
- 実装コストが低い
小規模な管理テーブルなら、オフセットは適切な選択です。REST APIにおけるページネーションでは、オフセット方式の構築手順を詳しく解説しています。
一方で、オフセットには本番環境で顕在化しやすい構造的な問題があります。
問題1:ページドリフト
オフセットは、ソート結果の先頭から行数を数えるだけです。クライアントがすでに見た行を追跡しません。そのため、リクエスト間にデータが追加・削除されると、ページの内容がずれます。
たとえば、作成日時の降順で1ページ目(1〜25行目)を表示している間に、新しい注文が3件追加されたとします。次のリクエストはOFFSET 25なので、最初のレスポンスにあった23〜25行目が26〜28行目へ押し出され、再び表示されます。これが重複です。
削除では逆の問題が起こります。1ページ目を読んでいる間に3行削除されると、OFFSET 25が未読の3行を飛ばしてしまいます。エラーにならないため、サイレントなデータ欠落になります。
月次レポートのように、データが変化しない用途では問題になりにくいでしょう。しかし、次の用途では重複や欠落につながります。
- アクティビティフィード
- 同期API
- 書き込みが続く中でページ単位に処理するスクリプト
問題2:深いオフセットはスキップする行をすべてスキャンする
OFFSET 500000は、500,001行目へ直接移動する操作ではありません。データベースは50万件のインデックスエントリをたどって破棄し、その後に25行を返します。コストはオフセット値nに比例し、O(n)で増加します。
200万行のPostgresordersテーブルにcreated_atのインデックスがある場合、目安は次のとおりです。
-
LIMIT 25 OFFSET 0:25件のインデックスエントリを読み取る。数ミリ秒。 -
LIMIT 25 OFFSET 100000:100,025件を読み取り、100,000件を破棄する。数十ミリ秒。 -
LIMIT 25 OFFSET 1500000:150万件を読み取る。数百ミリ秒を消費し、1ページのためにバッファとCPUを使う。
Markus Winand氏のUse The Index, Lukeにあるno-offsetの記事では、クエリプランを使ってこのコストを実証しています。
本番環境では、高オフセットのリクエストがスロークエリログを占有します。公開APIをページ単位で巡回するクローラーが1つあるだけで、p99レイテンシーが倍増することもあります。
カーソルベースページネーションの仕組み
カーソルベースページネーションは、キーセットページネーションとも呼ばれます。行数をスキップする代わりに、「特定のレコードの後にある行」を要求します。
カーソルはクライアントが最後に見た行を識別するため、サーバーは次のバッチへ直接シークできます。
SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
重要なのは、ソートキーを2列にしている点です。created_atだけでは一意とは限りません。同じミリ秒に複数の注文が作成される可能性があるためです。一意でないソートキーを使うと、ページ境界でレコードの重複や欠落が起こります。
idをタイブレーカーとして追加し、(created_at, id)の複合インデックスを作成すれば、順序が決定的になります。データベースは境界へ直接シークして25件だけを読み取るため、1ページ目でも60,000ページ目でもコストはほぼ一定です。
ただし、APIでこれらの値をそのまま公開するべきではありません。通常は、ソートキーを不透明なトークン(多くの場合base64)にエンコードします。
GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
不透明性は単なる難読化ではなく、API設計上の利点です。クライアントがカーソルを解析できなければ、次の変更を内部で行えます。
- ソートキーの変更
- シャードヒントの追加
- ストレージエンジンの変更
クライアントとの契約は、「サーバーが返したカーソルを、そのまま次のリクエストに返す」だけです。
トレードオフとして、47ページ目へ直接移動する機能はありません。カーソルが知っているのは「この行の後」だけなので、クライアントは1ページずつ進みます。合計件数も別途カウントクエリが必要です。数百万レコードのAPIページネーションを設計するためのガイドでは、スケーリングの考え方をさらに解説しています。
トレードオフの概要
| 項目 | オフセットページネーション | カーソルベースページネーション |
|---|---|---|
| 任意のページへのジャンプ | 可能(任意のページ番号) | 不可(順次移動のみ) |
| 合計件数 / ページ数 | 含めるのが容易 | 別途カウントクエリが必要 |
| 深いページのパフォーマンス | O(n)、深さとともに劣化 | どの深さでも1ページあたりO(1) |
| 書き込み時の安定性 | ドリフト(重複と欠落) | 安定(行に固定される) |
| 構築コスト | 非常に低い | 中程度(エンコーディング、タイブレーカー、インデックス設計) |
| 順序付けの要件 | 任意のORDER BYが機能 | 一意でインデックス付きのソートキーが必要 |
| ページURLのキャッシュ | 容易(URLは予測可能) | 困難(カーソルは移動ごとに異なる) |
| クライアント側の複雑さ | 低い | 低い(エンベロープが明確であれば) |
カーソル方式では、決定的なソートが必須です。たとえば、statusのように変更可能で一意でないカラムでソートを許可すると、キーセットロジックはすぐに複雑になります。
オフセットは柔軟なORDER BYを許容しますが、カーソルは一意でインデックス付きのソートキーを必要とします。
どちらを選ぶべきか?
ページネーション方式は、データの消費方法で決めます。
管理テーブルとダッシュボード:オフセット
数千行程度の内部ツールで、人間がページ番号をクリックし、「1,848件の結果」のような表示を必要とする場合です。データの変化が少なく、ページが浅いなら、ページジャンプを実現できるオフセットが有利です。
無限スクロールフィード:カーソル
フィードの47ページ目へ移動するユーザーはいません。ユーザーは「さらに読み込む」を繰り返し、データは継続的に追加されます。重複表示も目立つため、カーソルが適しています。
公開API:カーソル
コンシューマーの挙動を制御できないため、全ページを巡回するクライアントが現れます。オフセットでは深いページが問題になりますが、カーソルなら各ページのコストを抑えられます。また、不透明なトークンの背後で内部実装を変更できます。
REST APIページネーションガイドでは、URLとヘッダーの慣例を詳しく解説しています。
エクスポートと同期ジョブ:カーソル
200万件の注文を取得するバッチジョブには、次の2つが必要です。
- 同時書き込みがあっても行を見逃さないこと
- ページごとのコストが一定であること
オフセットはどちらも保証しません。カーソルなら、ジョブが140万行目で停止しても、カーソルを使ってその位置から再開できます。
経験則としては、次のように考えるとよいでしょう。
- 小規模で人間が閲覧し、件数やページ移動が必要:オフセット
- 大規模、リアルタイム、または公開API:カーソル
実際のAPIがどのように対応しているか
Stripe
Stripeは完全にカーソルベースです。リストエンドポイントはstarting_after(オブジェクトID)とlimitを受け付け、レスポンスにhas_moreを含めます。次ページを取得するには、最後に受け取った課金オブジェクトのIDを渡します。Stripeのページネーションドキュメントにパターンが示されています。
Stripeの書き込み量を考えると、合計件数をレスポンスに含めていない点も重要です。
GitHub REST API
GitHubのREST APIは、ほとんどのエンドポイントでpageとper_pageを公開し、Linkヘッダーで次ページや最終ページを示します。
ただし、GitHubのページネーションドキュメントでは、クライアントがURLを組み立てるのではなく、LinkヘッダーのURLに従うよう推奨しています。新しいエンドポイントはカーソルへ移行しています。大規模なリポジトリで深いオフセットを巡回すると、パフォーマンスに悪影響があるためです。
Slack
SlackはWeb APIをカーソルページネーションへ移行し、現在は新しいメソッドでこの方式を採用しています。
conversations.historyなどのメソッドはresponse_metadata.next_cursorを返します。空のカーソル文字列は、終端に到達したことを示します。Slackのページネーションドキュメントに詳細があります。
高トラフィックAPIの傾向は明確で、カーソル方式へ向かっています。
レスポンスエンベロープの設計
カーソルAPIの使いやすさは、レスポンスエンベロープで決まります。予測可能で一貫した形式にしましょう。
{
"data": [
{
"id": "ord_8846",
"customer_id": "cus_2201",
"total_cents": 12900,
"created_at": "2026-08-30T16:01:44Z"
}
],
"has_more": true,
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
堅牢な設計には、次の4つのルールが役立ちます。
- 常に
has_moreを返す。ページが短いことだけを理由に、クライアントが終端と判断しないようにする。取得後のフィルタリングで、途中のページが短くなることもある。 - 最終ページの
next_cursorはnullにし、その仕様を文書化する。空文字列を使うSlack方式でもよいが、形式を混在させない。 - 無効なカーソルは、空の200レスポンスではなく400で拒否する。破損したカーソルはクライアントのバグとして明示する。
- ソートキー以外の情報をエンコードする場合は、カーソルに署名またはバージョンを付ける。将来のスキーマ移行で役立つ。
Apidogで両方のスタイルをテストする
ページネーションのバグは、最後のページ、空のページ、削除されたアンカー行などの境界に潜みます。手動クリックだけでは見つけにくい問題も、チェーンされたテストシナリオなら検証できます。Apidogは、このようなワークフローのテストに適しています。
カーソルエンドポイント
2ステップのテストシナリオを作成します。
- 最初のリクエストでカーソルを抽出する
レスポンス後処理にJSONPath$.next_cursorを設定し、nextCursorなどの変数へ保存します。Apidogでは、レスポンスパネルからJSONPathを直接コピーできます。詳しくは、JSONPathでアサーションを設定し変数を抽出する方法を参照してください。
- 次ページ以降をループする
2番目のリクエストをForEachまたはループステップで囲み、{{nextCursor}}をカーソルパラメータとして渡します。各イテレーションで$.next_cursorを再抽出し、has_moreがfalseになったら終了します。
各ページで、次のアサーションを追加します。
- 前ページと
idが重複していない - ページサイズが
limitを超えていない
オフセットエンドポイント
同じ構造をカウンター変数で実装できます。
-
pageをインクリメントする - 最終ページまで
dataの長さがper_pageと一致することを確認する - 全ページを通じて
totalが一貫していることを確認する
エッジケース
各ケースを独立したステップにし、明示的なアサーションを追加します。
-
空のページ:0行に一致するフィルターを指定し、
dataが[]、has_moreがfalse、ステータスが200であることを確認する。 -
無効なカーソル:
cursor=not-a-real-cursorを送信し、ステータスが400で、機械可読なエラーコードが返ることを確認する。 - 削除されたアンカー行:注文を作成してカーソルを取得し、その注文を削除してからカーソルを使用する。エラーにならず、正しい位置から処理が継続することを確認する。
キーセット比較ではアンカー行の存在が不要なため、このケースを自然に処理できます。
ローカルでシナリオが通ったら、マージごとにCIで実行しましょう。Apidogを無料でダウンロードすれば、ループやアサーションを含むカーソルウォークシナリオを30分以内に実行できます。
よくある質問(FAQ)
カーソルページネーションは常に優れているのか?
いいえ。適度なデータセットに対してページ番号、合計件数、ランダムアクセスが必要な内部管理ツールでは、オフセットの方が適しています。
一方、データセットが大規模、書き込みが頻繁、またはAPIが公開されている場合は、カーソルが適しています。典型的な失敗は、公開リストAPIにオフセットを採用し、ローンチ後にO(n)のコストへ気づくことです。
カーソルページネーションで合計件数を取得するには?
同じフィルターで、別途SELECT COUNT(*)を実行します。提供方法は次のいずれかです。
- 専用のエンドポイント
-
include_count=trueのようなオプトインパラメータ
カウントは積極的にキャッシュしましょう。1分ごとに更新される概算値でも、多くのUI要件を満たせます。Stripeは合計件数を完全に省略しており、クライアントが実際にどれだけ必要としているかを考える材料になります。
1つのエンドポイントで両方の方式を提供できるか?
可能です。GitHubは移行期間中、実質的に両方を提供しています。
ただし、新しいAPIでは避けるのが無難です。2つの方式を提供すると、エッジケース、テストマトリックス、クライアントの混乱がそれぞれ増えます。エンドポイントごとに1つの方式を選びましょう。
新規に契約を設計する場合は、REST APIページネーションガイドを参考に、API全体でパラメータ名を統一してください。
カーソルのアンカー行が削除されたらどうなるか?
キーセットページネーションでは、基本的に問題ありません。
WHERE (created_at, id) < (?, ?)の比較は、アンカー行が存在することを要求しません。データベースは境界位置へシークし、そこから処理を続行します。
これは「行ルックアップとしてのカーソル」設計の利点です。ユーザーが問題を発見する前に、Apidogのテストシナリオで必ず検証しましょう。
Top comments (0)