ページネーションとは - API のカーソル方式とオフセット方式の比較

ページネーションは大量データを分割して返す手法。オフセット方式とカーソル方式の違い、パフォーマンス特性、REST/GraphQL での実装パターンを解説

API設計

ページネーションとは

ページネーション (Pagination) は、大量のデータを一度に返すのではなく、複数のページに分割して返す API 設計手法である。1 万件のデータを 1 回で返すと、レスポンスサイズが巨大になり、レイテンシが増大し、クライアントのメモリを圧迫する。

3 つの方式

分かれ目は「続きの位置をどう指すか」である。先頭から何件飛ばすかという件数で指すのがオフセット、直前に返した行の値で指すのがカーソルとキーセットだ。後者 2 つは機序としては同じもので、次の位置を表す鍵をクライアントに解読させない文字列へ包むか (カーソル)、id のような列の値をそのまま見せるか (キーセット) の違いにすぎない。包んでおくと、内部の並び順やキーの構成を後から変えても URL の形を保てる。

方式仕組みメリットデメリット
オフセット?page=3&limit=20シンプル、任意のページにジャンプ大きなオフセットで遅い
カーソル?cursor=abc123&limit=20大量データでも高速任意のページにジャンプ不可
キーセット?after_id=123&limit=20DB インデックスを活用、高速ソート順が固定

オフセットベース

ページ番号と 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.endCursorafter に渡して取り、続きの有無は 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 のように規約の側が方式を決めている場合は、それに従うほうが移植性の面でも無難だ。

この記事は役に立ちましたか?

関連用語

関連する記事