ページネーションとは - API のカーソル方式とオフセット方式の比較
ページネーションは大量データを分割して返す手法。オフセット方式とカーソル方式の違い、パフォーマンス特性、REST/GraphQL での実装パターンを解説
ページネーションとは
ページネーション (Pagination) は、大量のデータを一度に返すのではなく、複数のページに分割して返す API 設計手法である。1 万件のデータを 1 回で返すと、レスポンスサイズが巨大になり、レイテンシが増大し、クライアントのメモリを圧迫する。
3 つの方式
分かれ目は「続きの位置をどう指すか」である。先頭から何件飛ばすかという件数で指すのがオフセット、直前に返した行の値で指すのがカーソルとキーセットだ。後者 2 つは機序としては同じもので、次の位置を表す鍵をクライアントに解読させない文字列へ包むか (カーソル)、id のような列の値をそのまま見せるか (キーセット) の違いにすぎない。包んでおくと、内部の並び順やキーの構成を後から変えても URL の形を保てる。
| 方式 | 仕組み | メリット | デメリット |
|---|---|---|---|
| オフセット | ?page=3&limit=20 | シンプル、任意のページにジャンプ | 大きなオフセットで遅い |
| カーソル | ?cursor=abc123&limit=20 | 大量データでも高速 | 任意のページにジャンプ不可 |
| キーセット | ?after_id=123&limit=20 | DB インデックスを活用、高速 | ソート順が固定 |
オフセットベース
ページ番号と 1 ページの件数だけで位置が決まるため、リクエストの形が最も素直になる。条件に合う行を数えれば総ページ数も出せるので、ページ番号を並べた UI と相性が良い。
// GET /users?page=3&limit=20
const offset = (page - 1) * limit; // 40
const users = await db.query('SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2', [limit, offset]);
// レスポンス
{
"data": [...],
"pagination": { "page": 3, "limit": 20, "total": 1500, "totalPages": 75 }
}
落とし穴は 2 つある。1 つは深いページのコストである。OFFSET 10000 は「10,000 行目へ飛ぶ」という指示ではなく「先頭から順に 10,000 行を読んで捨て、その次から返す」という指示になる。インデックスをたどる場合でも捨てる分の走査は発生するため、後ろのページほど 1 回の取得が重くなる。上のレスポンスに入れた total も、条件に合う行を全部数えないと出ないので、一覧を開くたびに全件分の走査を足すことになる。
もう 1 つはページのずれだ。位置を件数で指しているので、1 ページ目を表示している間に先頭へ 1 件挿入されると、2 ページ目の先頭は 1 ページ目の末尾と同じ行になる。削除が起きれば逆に 1 件飛ぶ。件数を数えた時点のデータと次のページを取る時点のデータが別物になる以上、この方式では原理的に避けられない。
カーソルベース (推奨)
位置を「直前に返した最後の行の値」で指す。飛ばす件数を数える処理が消えるため、何ページ目でも走査量が変わらない。
// GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
const cursor = decodeCursor(cursorParam); // { id: 123 }
const users = await db.query(
'SELECT * FROM users WHERE id > $1 ORDER BY id LIMIT $2',
[cursor.id, limit]
);
const nextCursor = encodeCursor({ id: users[users.length - 1].id });
// レスポンス
{
"data": [...],
"pagination": { "nextCursor": "eyJpZCI6MTQzfQ", "hasMore": true }
}
WHERE id > 123 は範囲の開始位置をインデックスで直接引けるため、何ページ目であっても読む行数は limit 分で済む。
代わりに制約が 2 つ付く。並べ替えのキーに同じ値があるとき、比較を 1 列で書くと取りこぼす。ORDER BY created_at で同時刻の行が 3 件あると、WHERE created_at > '...' は残りの 2 件を飛ばしてしまう。キーは一意になるまで列を足し (ORDER BY created_at, id)、比較も WHERE (created_at, id) > ($1, $2) のように組で書く。もう 1 つは総ページ数を返せないことで、次があるかどうかは limit + 1 件を取って 1 件余ったかで判定するのが定石になる。
DynamoDB のページネーション
DynamoDB にはオフセットに相当する指定が無い。続きを取る手段は、応答の LastEvaluatedKey を次のリクエストの ExclusiveStartKey に渡す形だけで、任意のページへ飛ぶ操作は API の側に用意されていない。
const result = await ddb.send(new QueryCommand({
TableName: 'Orders',
KeyConditionExpression: 'userId = :uid',
ExpressionAttributeValues: { ':uid': userId },
Limit: 20,
ExclusiveStartKey: lastKey, // 前回の LastEvaluatedKey
}));
return {
items: result.Items,
nextKey: result.LastEvaluatedKey, // 次のページのカーソル
};
区切りの解釈で 3 点つまずく (2026 年 8 月時点の Query API 仕様)。Limit は「評価する項目数の上限」であって、一致した件数の上限ではない。1 回の呼び出しが読むのは最大 1 MB 分で、Limit に届く前にここで打ち切られる。FilterExpression は読み取ったあとに適用されるので、Items が空でも LastEvaluatedKey が返ることがある。20 件欲しいなら 1 回呼べば足りる、という前提では組めない。終端の判定は LastEvaluatedKey が空になったかどうかだけで行う (値が入っていても続きが空の場合はある)。
GraphQL のページネーション (Relay Cursor Connection)
Relay の Connection 規約は、行の集合を edges で包み、行ごとに再開位置を表す cursor を添える形を取る。次のページは pageInfo.endCursor を after に渡して取り、続きの有無は hasNextPage で判定する。カーソル方式をスキーマの形として固定したものと見ればよい。
query {
users(first: 20, after: "cursor123") {
edges {
node { id name email }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
実務での選択基準
ページ番号を見せる必要があるかどうかが、そのまま方式の分かれ目になる。
| 場面 | 選ぶ方式 | 理由 |
|---|---|---|
| 管理画面の一覧でページ番号を並べ、5 ページ目へ直接飛ばせたい | オフセット | 任意のページへジャンプできるのはこの方式だけで、カーソルは前後をたどることしかできない |
| 無限スクロールで下へ読み進める | カーソル | 直前の位置から続きを取るため、読み進めている間に新しい行が入っても同じ行を二度表示せずに済む |
| 件数の多い一覧を API で返す | カーソル | オフセットは指定した行数を DB がスキップしてから返すので、後ろのページほど遅くなる |
| DynamoDB から取得する | カーソル (LastEvaluatedKey) | 応答に含まれる LastEvaluatedKey を次のリクエストに渡す形で、続きの取得手段が最初から用意されている |
方式は後から変えにくい。?page=3 を公開した API を ?cursor=... へ移すと、クライアント側のページ送りの実装ごと書き換えになる。公開前に「ページ番号を見せる必要が本当にあるか」を決めておくのが安い。どちらを選んでも、並べ替えのキーに索引が張られていることが速さの前提になる点は変わらない (データベースインデックス)。GraphQL のように規約の側が方式を決めている場合は、それに従うほうが移植性の面でも無難だ。
この記事は役に立ちましたか?
関連用語
REST API
HTTP メソッドとリソース指向の URL で設計する Web API のアーキテクチャスタイル
GraphQL
クライアントが必要なデータだけを指定して取得できる API クエリ言語
データベースインデックス
検索クエリの高速化のためにデータベースが維持する補助的なデータ構造
インデント
コードの階層構造を視覚的に表現するための字下げ
Service Worker
ブラウザとネットワークの間でプロキシとして動作し、オフライン対応やキャッシュ制御を実現する Web API
Iterator パターン
コレクションの内部構造を公開せずに要素へ順番にアクセスする手段を提供するデザインパターン。JavaScript の Iterator プロトコル / Generator / Async Iterator と遅延評価の落とし穴を解説
関連する記事
技術書の知識を定着させる間隔反復法 - 読んだのに忘れる問題を解決する
技術書を読んでも内容を忘れてしまう原因を認知科学の観点から分析し、間隔反復法を使って知識を長期記憶に定着させる具体的な方法を紹介します。
技術書の読む順番戦略 - 複数冊を組み合わせて理解を加速させる
技術書を 1 冊ずつ読むのではなく、複数冊を戦略的に組み合わせることで、1 冊では届かない理解の深さと広さに達する方法を解説します。
深夜 3 時のデプロイ前に読み返したい 1 ページ
本番デプロイの直前、最終確認のチェックリストとして技術書の特定のページが役立つことがあります。緊張の場面で頼りになる「お守りの 1 ページ」の見つけ方と活用法。