?page=2 や ?limit=20 くらいで十分に見える。ところが、いざAPIとして設計しようとすると、厄介な問いがたちまち湧いてくる。
結果をどの順序で固定するか。次のページの境界を、なにで表すか。URLとして共有したり再訪したりできるのか。データの更新中に重複や取りこぼしが起きても、構わないのか。
DBの側も黙ってはいない。そのクエリをどう実行するのか、という問題が控えている。フロントエンドのキャッシュやブラウザバックとも、折り合いをつけなければならない。つまりページネーションは、UI、API、DB、運用のちょうど境目に居座っている設計テーマなのだ。
以下、ページネーションのクエリ設計をpage-based、offset-based、cursor-based、time-based の四つに分けて整理していく。本にたとえるなら、ページ番号で開くか、頭から枚数を数えてめくるか、しおりを挟むか、日記のように日付で引くか、の違いである。
最初に問うべきこと
個人的には、最初に考えるべき問いは一つだと思っている。その一覧で、ページ番号へ飛ぶ体験は本当に必要か、である。必要ならpage / offset 系を検討し、不要なら、まず cursor を検討する。さらに「何ページ目か」より「いつからいつまでか」が意味を持つなら、time-based を前面に出す。
ざっくり使い分けると、次のようになる。
| 方式 | 代表クエリ | 向いている場面 | 強み | 弱み |
|---|---|---|---|---|
| page-based | ?page=3&perPage=20 | 検索結果、管理画面、SEOを意識した一覧 | 人間にわかりやすい。URLとして共有しやすい | 深いページで重くなりやすい。更新中のデータに弱い |
| offset-based | ?offset=40&limit=20 | 小〜中規模一覧、単純なページ送り | 実装が簡単。任意位置へ移動しやすい | 大きな offset が重い。重複、取りこぼしが起きやすい |
| cursor-based | ?cursor=opaque&limit=20 | タイムライン、通知、チャット、同期API、無限スクロール | 深いページでも性能が安定しやすい。更新中のデータに比較的強い | 任意ページジャンプが難しい。カーソル設計が必要 |
| time-based | ?since=...&until=...&limit=50 | ログ、イベント、監査履歴、メッセージ履歴 | 時間範囲で探す要件と相性がよい | timestampだけでは境界が不安定になりやすい |
page を選ぶと、あとでつらくなることがある。かといって、管理画面のように「10ページ目へ飛びたい」という体験があるなら、cursorだけでは扱いにくい。ページネーションは、技術的に優れた方式を一つ選ぶものではない。一覧の使われ方に合わせて選ぶものなのである。
ページ番号という分かりやすさ
page-based pagination は、外部仕様としてpage と perPage を受け取る方式だ。
GET /articles?page=3&perPage=20
ユーザーから見れば、これがいちばん分かりやすい。「3ページ目を開く」「URLを共有する」「ブラウザバックで戻る」といった体験とも相性がよい。検索結果やカテゴリ一覧、管理画面のテーブルなどでは、今でもごく自然な選択肢になる。
ただし実装上は、多くの場合、内部で offset に変換される。
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
つまり、APIとしては page-based でも、DBに対しては offset-based として動いていることが多い。そのため、深いページを延々と掘る使い方や、データが頻繁に追加・削除される一覧では、offset の弱点がそのまま表に出てくる。
page-based が向いているのは、ページ番号へ直接移動したいときだ。?page=n をURLとして共有したいときや、SEOを意識した一覧ページにしたいときもそうである。データ量がそこまで大きくなく、深いページを大量に巡回する使い方でもなければ、なんの不足もない。翻って、タイムラインや通知一覧のように「次を読む」だけでよいなら、ページ番号がなくて困ることはまずない。
offsetの弱点
offset-based pagination は、offset と limit をそのまま指定する方式である。
GET /articles?offset=40&limit=20
SQLにも素直に落とせる。
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
実装は簡単で、考え方も分かりやすい。小さな一覧や社内の管理画面なら、これで十分なことも多い。
ところが offset は、深くなるほど重くなりやすい。LIMIT 20 OFFSET 100000 は、「20件しか返さないから軽い」わけではない。DBは100000件目までの位置を見つけたうえで、その次の20件を返さなければならないのだ。むろんインデックスが効けばある程度は速くなるが、スキップする行が増えるほど仕事が増えるという性質は残る。
もう一つの問題は、更新中のデータに弱いことだ。たとえば1ページ目で、AからEまでの5件を見たとする。2ページ目を開く前に、新しい記事Xが先頭に追加された。並びはXABCDEに変わるので、offset=5 で次を取ると、先頭に来るのはさっき見たはずのEになる。
こうして E が重複して見える可能性がある。逆に途中のデータが削除されれば、まだ見ていない1件を飛ばしてしまう。offset は「現在の並びの何番目か」を基準にしているので、並び自体が変われば境界もずれるのである。
offset-based が向いているのは、データ量が小さく、深いページをあまり開かず、更新頻度も低い一覧だ。実装の簡単さを優先したいときや、ページ番号ジャンプが必要なときにも選択肢になる。一方、大量データの全件同期や、頻繁に更新されるタイムラインでは避けたい。
5000ページ目の注文一覧
実務でありがちな例を一つ考えてみる。注文一覧APIに、次のようなリクエストが来たとする。
GET /api/orders?page=5000&size=100
一見すると、100件の注文を取ってくるだけの普通のリクエストだ。しかし先に書いたとおり、page-based pagination は内部で offset に変換されることが多い。page が1始まりなら offset は (page - 1) × size なので、今回は (5000 - 1) × 100 = 499900 になる。SQLにすると、だいたいこんな形だ。
SELECT
id,
status,
created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 100
OFFSET 499900;
返すデータは100件なのに、DBはその100件にたどり着く前に、先頭から499900件分の位置をたどらなければならない。本でいえば、50万枚近くを指でめくって数え終えてから、ようやく読み始めるようなものだ。
同じsize=100 でも、ページが深くなるほど処理量は膨らんでいく。page=1 なら OFFSET 0、page=100 なら OFFSET 9900、page=1000 なら OFFSET 99900、そして page=5000 で OFFSET 499900 だ。API上は page でも、DB上では offset-based として動いているがゆえの現象である。
もっとも、この問題に遭遇したからといって、すぐに cursor-based へ乗り換えればよいとは限らない。まず確かめたいのは、実際にユーザーが5000ページ目を開いているのかどうかだ。バッチやクローラーが、全ページを順番に巡回しているだけかもしれない。
ほかにも見ておきたい点がある。ページ番号を指定して直接移動する必要が、本当にあるのか。一覧に検索条件や期間指定を足せないか。ORDER BY に対応するインデックスはあるか、正確な totalCount は要るのか。
たとえば人間が注文を探すために5000ページ目まで潜っているなら、問題はページネーション方式より検索UIのほうにある。5000ページをめくって目当ての注文を探すのは、もはや業務というより修行だ。注文番号、顧客名、ステータス、作成日時などで絞り込めるようにしたほうが、利用者にとってもDBにとってもよほど優しい。
一方、バッチ処理が注文を全件走査しているなら、ページ番号へ移動する機能はそもそも要らない。その場合は、cursor-based pagination のほうが要件に合っている。
cursor-based に変えるなら、まず注文一覧の並び順を次のように決める。
ORDER BY created_at DESC, id DESC
前回取得した最後の注文が、次の値だったとする。
{
"createdAt": "2026-07-01T10:00:00Z",
"id": 12345
}
次のページでは、この値より後ろにある注文を取る。
SELECT
id,
status,
created_at
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 100;
APIでは、この境界値を cursor として渡す。
GET /api/orders?limit=100&cursor=opaqueCursor
offset のように「先頭から何件飛ばすか」を指定するのではない。「どの注文の続きから取るか」を指定するわけだ。対応するインデックスも、並び順に合わせて用意しておく。
CREATE INDEX idx_orders_created_at_id
ON orders (created_at DESC, id DESC);
これで、5000ページ分読み進めたあとでも、毎回499900件をスキップする必要はなくなる。
ただし cursor-based pagination では、「5000ページ目へ直接移動する」という操作は難しくなる。だから、ページ番号ジャンプが本当に必要な画面では、page-based を維持する選択もある。
その場合に打てる手はいくつかある。検索条件や期間指定で、先に対象件数を絞る。移動できる最大ページ数を制限したり、古いデータを年月単位で分けたりする。深いページへのアクセスを計測し、ORDER BY とインデックスが合っているかを確かめ、全件取得は非同期エクスポートとして切り離すのもよい。
たとえば、管理画面では page-based pagination を維持し、バッチや外部連携用のAPIでは cursor-based pagination を使う。そういう分け方もできる。
# 管理画面用
GET /api/orders?page=10&size=100
# バッチ、同期処理用
GET /api/order-exports?cursor=opaqueCursor&limit=1000
同じデータを扱っていても、利用目的によってページネーション方式を分けて一向に構わないのだ。
なお、深いページが遅いときは、推測だけで判断せずに実行計画を確認したい。
EXPLAIN (ANALYZE, BUFFERS)
SELECT
id,
status,
created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 100
OFFSET 499900;
見るべきなのは、最終的に返ってきた100行だけではない。走査した行数、使われたインデックス、Sortの有無、Buffersの増え方を確かめる。offsetが深くなるにつれて、実行時間がどう変わるかも見ておきたい。浅いページと深いページを比べれば、返却件数は同じでも、深い offset ほど多くの行を処理していると分かるはずだ。
結局のところ、page=5000 が遅いという問題は、SQLを少しいじれば片づくとは限らない。考えるべきことは三つある。一つはDBが深い offset をどう処理しているか、もう一つは利用者が任意のページへ移動する必要があるかどうか。そして、一覧を全件走査している処理を別のAPIに切り出せないか、である。
ページネーション方式は、クエリの性能だけでなく、UIやバッチ処理、データの探し方まで含めて選ぶべきものなのである。
境界を値で示すcursor
cursor-based pagination は、ページ番号ではなく「前回どこまで読んだか」を渡す方式だ。本でいえば、しおりにあたる。
GET /articles?limit=20
GET /articles?cursor=opaqueCursor&limit=20
レスポンスでは、次ページ用のカーソルを返す。
{
"items": [
{ "id": "a1", "title": "Paginationのクエリ設計" }
],
"pageInfo": {
"nextCursor": "opaque-next",
"hasNextPage": true
}
}
cursor の本質は、結果集合の境界を一意に決める値であることに尽きる。たとえば id が単調増加で、一覧の並びも id DESC でよいなら、こう書ける。
SELECT id, title, created_at
FROM articles
WHERE id < $1
ORDER BY id DESC
LIMIT 20;
offset と違って、条件は「どの値より後か」になる。そのため、深いページでも比較的安定した性能になりやすい。
ところが実際の一覧では、id DESC だけで済むとは限らない。よくあるのは created_at DESC の一覧だ。このとき、created_at だけを cursor にするのは危うい。created_at が同じ記事が、複数並ぶことがあるからだ。
ORDER BY created_at DESC
この並びは、人間には自然に見える。しかし同じ created_at の記事が複数あると、その中の順序は安定しない。ページの境界がちょうどその同値グループに当たれば、重複や取りこぼしの原因になる。
そこで実務では、created_at + id のような複合キーで一意な順序を作ることが多い。
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
このときは、インデックスも並び順に合わせる。
CREATE INDEX idx_articles_created_at_id
ON articles (created_at DESC, id DESC);
フィルタ条件があるなら、それも含めて設計する。
CREATE INDEX idx_articles_tenant_created_at_id
ON articles (tenant_id, created_at DESC, id DESC);
要するに cursor-based pagination では、ソート順とカーソルとインデックスを三点セットで考えることが肝心なのだ。APIの見た目だけ cursor で着飾っても、中身が伴わなければ意味がない。順序が不安定だったり、インデックスが合っていなかったりすれば、期待したご利益はなかなか出てこない。
中身を見せないcursor
cursor は、クライアントから見て opaque な文字列にしておくのが扱いやすい。つまり、クライアントが中身を解釈しない token として返す。
{
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTA5VDAwOjAwOjAwWiIsImlkIjoiMTIzIn0="
}
中身としては、たとえば次のような情報を持たせる。
{
"createdAt": "2026-07-09T00:00:00Z",
"id": "123"
}
ただし、これをそのままURLに出すより、base64などでエンコードしておく。こうしておくと、あとから cursor の中身を変えやすい。最初は id だけだった cursor を、のちに created_at + id に変えたくなることは十分にありうる。
そのときクライアントが cursor の中身に依存していると、変更はたちまち難しくなる。opaque cursor にしておけば、クライアントは受け取った文字列を次のリクエストにそのまま渡すだけでよい。しおりの裏になにが書いてあるかは、読者の知ったことではないのだ。
ついでに言えば、filter や sort を変えられるAPIでは、cursor がどの条件に対するものかも重要になる。
GET /articles?tag=api&sort=createdAtDesc&cursor=...
この cursor は、tag=api かつ sort=createdAtDesc の結果集合に対する cursor だ。tag や sort が変わったあとに古い cursor を使えば、その意味は壊れる。別の本に、前の本のしおりを挟むようなものである。そこで、cursor の中に filter/sort の情報を含めて検証するか、条件が変わったら cursor を無効にする設計にしておきたい。
時間で切るtime-based
time-based pagination は、ページ送りというより「時間範囲で切る」方式である。
GET /events?since=2026-07-01T00:00:00Z&until=2026-07-31T23:59:59Z&limit=50
ログ、イベント、監査履歴、メッセージ履歴、時系列メトリクスなどでは、「何ページ目か」よりも「いつからいつまでか」のほうが重要になる。この場合、time-based はごく自然な選択だ。
SELECT id, event_type, created_at
FROM events
WHERE created_at >= $1
AND created_at < $2
ORDER BY created_at DESC, id DESC
LIMIT 50;
ただし、時間だけで完全なページングをしようとすると、境界で同じ timestamp を持つデータが問題になる。そこで実運用では、time-based と cursor-based を組み合わせることが多い。
GET /events?since=2026-07-01T00:00:00Z&until=2026-07-31T23:59:59Z&cursor=opaque&limit=50
SQLでは、時間範囲で絞りつつ、ページの境界は複合 cursor で見る。
SELECT id, event_type, created_at
FROM events
WHERE created_at >= $1
AND created_at < $2
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT 50;
time-based は、検索条件としての時間と、ページ境界としての cursor を分けて考えると見通しがよくなる。日付で開いた日記にも、しおりは要るわけだ。
itemsとpageInfo
REST APIなら、個人的にはitems と pageInfo を分ける形が扱いやすい。たとえば次のリクエストに対して、
GET /articles?limit=20&cursor=opaque
こんなレスポンスを返す。
{
"items": [
{
"id": "a1",
"title": "Paginationのクエリ設計",
"createdAt": "2026-07-09T00:00:00Z"
}
],
"pageInfo": {
"nextCursor": "opaque-next",
"prevCursor": "opaque-prev",
"hasNextPage": true,
"hasPreviousPage": false
}
}
items は実データで、pageInfo はページング制御に必要な情報だ。この二つを分けておくと、フロントエンド側もずいぶん扱いやすくなる。
hasNextPage は、limit + 1 件取ると実装しやすい。たとえば limit=20 なら、DBからは21件取る。
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;
21件取れたら、20件だけ返して hasNextPage: true にする。20件以下なら hasNextPage: false だ。この方法なら、別途 COUNT(*) を毎回打たなくても、次があるかどうかを判断できる。
一方で totalCount は便利だが、巨大な一覧では重くなりやすい。殊に検索条件が複雑だったり行数が多かったりすると、毎回正確な件数を返すだけで馬鹿にならないコストがかかる。だから、必要な画面だけ totalCount を返す、概算にする、非同期で返す、といった選択肢も考えておきたい。
GraphQLのConnection
GraphQLでは、Relay Connection 形式がよく使われる。
query {
articles(first: 20, after: "opaque-cursor") {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
REST の items + pageInfo と比べると、少々ものものしい佇まいだ。とはいえ、考え方は近い。node が実データ、cursor がその要素の位置、pageInfo が次や前があるかを表している。GraphQLでは、前方向と後方向のページングも仕様として表現しやすい。
articles(first: 20, after: "cursor")
articles(last: 20, before: "cursor")
RESTで同じことをするなら、Stripeのように starting_after / ending_before といった具合に名前を分けるのが分かりやすい。
GET /articles?limit=20&startingAfter=...
GET /articles?limit=20&endingBefore=...
後方向のページングは、SQLでは逆順に取ってからアプリ側で反転する実装にすることが多い。現在の表示順が created_at DESC, id DESC なら、前のページを取るときは > 条件で上方向へ取りにいく。
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT 20;
取得した配列をアプリ側で反転して、表示順を DESC に戻せばよい。
URLとキャッシュ
フロントエンドの実装では、ページネーション方式によって状態の持ち方が変わる。page-based なら、/articles?page=3&tag=api&sort=createdAtDesc のようにURLへ状態を寄せやすい。この形は共有、再訪、ブラウザバックとの相性がよく、検索結果やカテゴリ一覧ではかなり扱いやすい。
一方、無限スクロールは cursor-based と相性がよい。
useInfiniteQuery({
queryKey: ["articles", filters, sort, pageSize],
queryFn: ({ pageParam }) =>
fetchArticles({
cursor: pageParam,
limit: pageSize,
filters,
sort,
}),
initialPageParam: null,
getNextPageParam: (lastPage) =>
lastPage.pageInfo.nextCursor ?? undefined,
});
ここで大事なのは、queryKey に一覧を定義する条件を全部入れておくことだ。
["articles", filters, sort, pageSize]
cursor は、その queryKey の中での継続状態として扱う。filters や sort が変わったら、それはもう別の一覧だ。そうしないと、別条件の結果に対して古い cursor を使ってしまう。
ブラウザバックまで自然にしたいなら、どこまでをURLに残すかも考えなければならない。page-based なら、?page=3 をURLに置けば済む。cursor-based の無限スクロールでは、URLに最後の cursor を置くのか、読み込み済みのページ列を history state に持つのか、あるいは戻ったときに再取得するのかを決める必要がある。どれを選ぶかは、UIに何を期待するか次第である。
PostgreSQLで確かめる
理屈だけで終わらせるより、PostgreSQLで実際に試してみるとずっと腹に落ちる。たとえば検証用に、100万件ほどのデータを入れるテーブルを用意する。
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 1,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_articles_created_at_id
ON articles (created_at DESC, id DESC);
CREATE INDEX idx_articles_tenant_created_at_id
ON articles (tenant_id, created_at DESC, id DESC);
そこに generate_series で100万件を流し込み、統計情報を更新しておく。
INSERT INTO articles (tenant_id, title, body, created_at)
SELECT
(random() * 9)::int + 1,
'title-' || gs,
repeat('x', 200),
now() - ((gs % 100000) || ' seconds')::interval
FROM generate_series(1, 1000000) AS gs;
ANALYZE articles;
offset-based は、浅い offset と深い offset を比べる。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 900000;
cursor-based は、複合 cursor を使う。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
実行時間だけでなく、Buffers、scan type、sort の有無、上流ノードの rows も見ておきたい。index が期待どおりに使われているかも要確認だ。深い offset では、返すのは20件でも、その位置にたどり着くまでに大量の行を処理していることが見えてくるはずである。
一方の cursor-based は、index の条件で開始位置を絞れる。だから深い位置でも、一定に近い挙動になりやすいのだ。
一覧の読まれ方から決める
管理画面や検索結果なら、page-based は今でも自然な選択肢である。ただ、大量データを順番にたどるAPI、タイムライン、通知、チャット、同期処理では、まず cursor-based を検討したい。ログや監査履歴のように時間範囲そのものが意味を持つなら、time-based と cursor-based を組み合わせるのが扱いやすい。
以上、ページネーションのクエリ設計について整理した。page=2 は簡単だが、それだけで十分とは限らない。問うべきことは、見た目よりずっと多い。
ページ番号へ飛ぶ必要の有無、一覧の順序が一意に固定されているかどうか、更新中の重複や取りこぼしの許容範囲。DBが深いページをどう処理するか、APIレスポンスが次の一手をクライアントに伝えているか、そしてフロントエンドのURLやキャッシュ、ブラウザバックと噛み合っているか。どれ一つとして、page=2 の見た目からは窺い知れない。
ページネーションは、limit と offset の話ではない。その一覧がどう読まれ、どの順序で固定され、次になにを返せばクライアントが迷わないのか。そこまで決めて、はじめて一覧の設計と言える。たかがページのめくり方、されどページのめくり方なのだ。