あらゆるリストのエンドポイントは、最終的に同じ疑問に直面します。200万件の注文をクライアントが順に見ていけるように、どうやってページに分割するか?オフセットによるページネーションを選べば、シンプルなSQLとユーザーが理解しやすいページ番号が得られます。カーソルベースのページネーションを選べば、安定した結果と、どの深さでも一貫したレイテンシーが得られますが、「47ページにジャンプ」といった機能は失われます。
ほとんどのチームは、どのチュートリアルでもデフォルトであるためオフセットを選びます。しかし、注文テーブルが数百万行に達すると、4,000ページ目でタイムアウトが発生し始め、ユーザーはスクロール中に同じレコードが2回表示されると報告するようになります。このガイドでは、両方のスタイルの仕組み、オフセットが破綻する場所、StripeやSlackがカーソルを採用する理由、そしてApidogでチェーンリクエストを使ってどちらのスタイルもテストする方法について説明します。読み終える頃には、あなたのエンドポイントにどちらが最適か正確にわかるでしょう。
まず全体像を知りたい場合は、当社のAPIページネーションガイドがすべての戦略を並べて解説しています。この記事では、最も重要な2つの方法について深く掘り下げます。
オフセットページネーションの仕組み
オフセットページネーションはSQLに直接マッピングされます。クライアントはページ番号とページサイズを送信し、サーバーはそれらをLIMITとOFFSETに変換します。
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におけるページネーションのステップバイステップガイドでは、完全なオフセット構築について解説しています。
しかし、オフセットには2つの構造的な問題があり、どちらも開発段階では表面化しません。両方とも本番環境で現れます。
問題1:ページドリフト
オフセットはソートされた結果の先頭から行数を数えます。クライアントがすでにどの行を見たかについては何も知りません。そのため、リクエスト間にデータが挿入または削除されると、クライアントの視点から見てページがずれてしまいます。
ユーザーが最新順にソートされた注文の1ページ目(1行目から25行目)を読み込んだとします。彼らが読んでいる間に、新しい注文が3件到着しました。次にユーザーが2ページ目をリクエストすると、それはOFFSET 25です。最初のレスポンスの23、24、25行目は、26から28行目の位置に押し下げられています。ユーザーはそれらを再び目にすることになります。重複です。
削除の場合は逆になります。ユーザーが1ページ目を読んでいる間に3行が削除されると、OFFSET 25はユーザーが一度も見ていない3行をスキップしてしまいます。これはサイレントなデータ損失であり、誰もエラーを受け取りません。
誰もリアルタイムでスクロールしない月次レポートのようなものでは、ドリフトは無害です。しかし、アクティビティフィード、同期エンドポイント、または書き込みが継続する中でスクリプトがページごとに処理を進めるようなものでは、ドリフトはレコードの重複や欠落を意味します。これはユーザーに気づかれます。
問題2:深いオフセットはスキップするすべてをスキャンする
OFFSET 500000は、500,001行目にテレポートするわけではありません。データベースは50万件のエントリをインデックスでたどり、それらを破棄してから25行を返します。コストは深さに比例して増大します。つまり、nがオフセット値である場合、O(n)となります。
具体的な数字がこれを現実的にします。200万行のPostgresのordersテーブルに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つのクローラーによるものです。たった1つのクライアントによって、あなたのp99は倍増する可能性があります。
カーソルベースページネーションの仕組み
カーソルベースページネーションは、キーセットページネーションとも呼ばれ、行カウンターを廃止します。「50行スキップ」する代わりに、クライアントは「この特定のレコードの後の行をください」と伝えます。カーソルはクライアントが最後に見た行を識別するため、サーバーは次のバッチに直接シークできます。
SQLでは、OFFSETの代わりにソートキーに対する行比較を使用します。
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だけでは一意ではありません。2つの注文が同じミリ秒に着地する可能性があり、一意でないソートキーはページ境界でレコードがスキップされたり重複したりすることを意味します。タイブレーカーとしてidを追加することで、順序付けが完全になり、ページネーションが正確になります。(created_at, id)の複合インデックスがあれば、データベースは境界に直接シークし、25件のエントリを読み取ります。1ページ目も60,000ページ目も同じコストです。
ただし、APIはこれらの生の値を公開すべきではありません。実際の実装では、ソートキーを不透明なトークン(通常はbase64)にエンコードします。
GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
不透明性は、それ自体を難読化するためではなく、設計上の決定です。カーソルを解析できないクライアントは手動でURLを作成できないため、ソートキーの変更、シャードヒントの追加、ストレージエンジンの切り替えなどを、誰も壊すことなく自由に行えます。契約は「我々が提供したものをそのまま返す」となり、それ以上ではありません。
トレードオフ:47ページ目はありません。カーソルは「この行の後」しか知らないため、クライアントは一度に1ページずつ(前のカーソルを発行すれば後方にも)進んでいきます。合計件数も無料で提供されるわけではなく、カウントは別のクエリです。データセット自体が巨大な設計の場合、数百万レコードのAPIページネーションを設計するためのガイドでは、スケーリングの側面をより深くカバーしています。
トレードオフの概要
| 項目 | オフセットページネーション | カーソルベースページネーション |
|---|---|---|
| 任意のページへのジャンプ | 可能(任意のページ番号) | 不可(順次移動のみ) |
| 合計件数 / ページ数 | 含めるのが容易 | 別途カウントクエリが必要 |
| 深いページのパフォーマンス | O(n)、深さとともに劣化 | どの深さでも1ページあたりO(1) |
| 書き込み時の安定性 | ドリフト(重複と欠落) | 安定(行に固定される) |
| 構築コスト | 非常に低い | 中程度(エンコーディング、タイブレーカー、インデックス設計) |
| 順序付けの要件 | 任意のORDER BYが機能 | 一意でインデックス付きのソートキーが必要 |
| ページURLのキャッシュ | 容易(URLは予測可能) | 困難(カーソルは移動ごとに異なる) |
| クライアント側の複雑さ | 低い | 低い(エンベロープが明確であれば) |
この表には、強調すべき微妙な点があります。カーソルページネーションは決定的なソートを要求します。もしあなたのエンドポイントが、statusのような変更可能で一意ではないカラムでクライアントにソートを許可する場合、キーセットロジックはすぐに苦痛なものになります。オフセットはルーズな順序付けを許容しますが、カーソルはそれを許しません。
どちらを選ぶべきか?
データの消費方法に合わせてスタイルを選びましょう。
管理テーブルとダッシュボード:オフセット。数千行の内部ツール、人間がページ番号をクリックし、「1,848件の結果」という表示がある場合。ドリフトは問題にならず、深さは浅く、ページジャンプは実際の機能です。構築コストの面でオフセットが有利です。
無限スクロールフィード:カーソル。フィードの47ページ目に飛ぶ人はいません。ユーザーは常に「もっと読み込む」だけで、書き込みは絶え間なく行われ、重複は目に見えて恥ずかしいものです。これは典型的なカーソルのケースです。
公開API:カーソル。あなたはコンシューマをコントロールできません。誰かがすべてのページを巡回するループを作成するでしょうし、オフセットでは、深いページが午前3時の問題となります。カーソルはすべてのページを安価に保ち、不透明なトークンの背後で内部を進化させることができます。当社のREST APIページネーションガイドでは、URLとヘッダーの慣例を詳しく解説しています。
エクスポートと同期ジョブ:カーソル。200万件すべての注文をプルするバッチジョブには、2つの保証が必要です。同時書き込みがあっても行を見逃さないこと、そしてページあたりのコストが一定であること。オフセットはどちらも提供しません。カーソルは、ジョブが140万行目で停止した場合でも、無料の再開地点を提供します。
率直な経験則:小規模で人間が閲覧し、件数が多いインターフェースにはオフセット。大規模、リアルタイム、または公開されているものにはカーソル。
実際のAPIがどのように対応しているか
Stripeは完全にカーソルベースです。すべてのリストエンドポイントはstarting_after(オブジェクトID)とlimitを受け入れ、レスポンスにはhas_moreが含まれます。次ページの課金データを取得するには、最後に受け取った課金のIDを渡します。Stripeのページネーションドキュメントにパターンが示されています。彼らの書き込み量では、意図的に合計件数がどこにもないことに注目してください。
GitHubのREST APIは、ほとんどのエンドポイントで依然としてpageとper_pageを公開しており、Linkヘッダーが次ページと最終ページを指しています。しかし、GitHubのページネーションドキュメントを注意深く読むと、クライアントにページURLを構築するのではなく、Linkヘッダーをそのまま従うように指示しており、新しいエンドポイントはカーソルに移行しています。これは、大規模なリポジトリに対する深いオフセットウォークがパフォーマンスに悪影響を与えるためです。
SlackはWeb APIをカーソルページネーションに移行し、現在ではすべての新しいメソッドがこのアプローチを使用するようマークしています。conversations.historyのようなメソッドはresponse_metadata.next_cursorを返し、空のカーソル文字列は終わりに達したことを意味します。これはSlackのページネーションドキュメントに記載されています。
3つの高トラフィック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の空文字列の慣習も機能しますが、どちらかを選んで決して混ぜないでください。 - 無効なカーソルは400で拒否する、空の200ではない。破損したカーソルはクライアントのバグであり、それを隠蔽すると誰かがデバッグに1日を費やすことになります。
- ソートキー以外のものをエンコードしている場合は、カーソルペイロードに署名またはバージョンを付加する。次のスキーマ移行時にそれが役立つでしょう。
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が公開されている場合は、カーソルの方が優れています。失敗パターンは、公開リストエンドポイントにデフォルトでオフセットを使用し、ローンチ後にO(n)のコストを発見することです。
カーソルページネーションで合計件数を取得するにはどうすればよいか?
同じフィルターで別途SELECT COUNT(*)を実行します。これは別のエンドポイントとして提供するか、include_count=trueのようなオプトインクエリパラメータとして提供できます。積極的にキャッシュしてください。1分ごとに更新されるおおよその件数で、ほとんどのUIの要件は満たされます。Stripeは合計件数を完全にスキップしており、これによりクライアントが実際にどれほどの頻度でそれを必要としているかがわかります。
1つのエンドポイントで両方のページネーションスタイルを提供できるか?
可能です。GitHubは移行期間中に事実上そうしていますが、新しいAPIでは避けるべきです。2つのスタイルは、2セットのエッジケース、2つのテストマトリックス、そしてどちらを使用すべきかというクライアントの混乱を意味します。エンドポイントごとに1つを選びましょう。ゼロから契約を設計する場合、当社のREST APIページネーションガイドのパターンに従えば、表面全体でパラメータの命名を一貫させることができます。
カーソルのアンカー行が削除されたらどうなるか?
キーセットページネーションでは、何も壊れません。WHERE (created_at, id) < (?, ?)の比較は、アンカー行が存在することを必要としません。境界位置にシークして続行します。これは「行ルックアップとしてのカーソル」設計に対する実際の利点であり、ユーザーが見つける前にApidogのテストシナリオでアサートする価値のあるエッジケースです。
