offset方式は「先頭から何件飛ばすか」でページを指定し、cursor方式は「前のページの最後の行より後ろ」を条件にして次のページを取得する方式です。offset方式は好きなページ番号へ移動できる一方、深いページほど読み飛ばす行が増えて遅くなり、途中でデータが増減すると重複や欠落が起きます。cursor方式はどれだけ深いページでも速さがほぼ一定で、データの増減にも強い一方、「25ページ目へ移動」のようなジャンプはできません。

この記事では、PostgreSQL 16で100万件のテーブルを作り、両方式の応答時間とEXPLAINを実測で比較します。さらに、OFFSETで重複・欠落が起きる様子を実際に再現し、created_atとidを組み合わせた複合カーソル、Base64のカーソルトークンを返すNode.jsのAPIをlocalhostで動かして確認した結果、総件数(COUNT)の扱い、使い分けの判断表まで紹介します。

offsetとcursorの違いの早見表

観点 offset方式 cursor方式(キーセット)
SQL ORDER BY ... LIMIT 20 OFFSET 500000 WHERE (created_at, id) < (前の最後の値) ORDER BY ... LIMIT 20
リクエスト ?page=25001 ?cursor=eyJjIjoi...
深いページの速さ 遅くなる(実測302ms) ほぼ一定(実測0.019ms)
途中でデータが増減 重複・欠落が起きる 起きにくい
任意のページへ移動 できる できない(前後の移動のみ)
総ページ数の表示 しやすい 別途COUNTが必要
向いている画面 管理画面の一覧、件数の少ない検索結果 無限スクロール、タイムライン、API、バッチの全件処理

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ページネーションのoffsetとcursorの違い|100万件で実測比較」

2つの方式のSQL

新しい記事から順に20件ずつ表示する場合です。

-- offset方式:2ページ目(20件飛ばして20件)
SELECT id, title, created_at FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;

-- cursor方式:1ページ目の最後の行(created_at, id)より後ろの20件
SELECT id, title, created_at FROM articles
WHERE (created_at, id) < ('2026-01-06 18:53:10+00', 999981)
ORDER BY created_at DESC, id DESC
LIMIT 20;

OFFSETは「何件目から」、cursorは「どの行の次から」を指定します。cursor方式は、OFFSETを使わずに位置をWHEREの条件で表すため、「キーセットページネーション」「シーク法」とも呼ばれます。

flowchart LR
  subgraph OFFSET["OFFSET 500000"]
    A1["先頭から50万件を<br/>読んで捨てる"] --> A2["次の20件を返す"]
  end
  subgraph CURSOR["cursor"]
    B1["インデックスで<br/>前回の位置へ移動"] --> B2["そこから20件を返す"]
  end

100万件で実測:深いページの応答時間

検証に使ったテーブル

CREATE TABLE articles (
  id bigserial PRIMARY KEY,
  title text NOT NULL,
  body text NOT NULL,
  created_at timestamptz NOT NULL
);
-- 1秒あたり2件 → created_atが同じ行が2件ずつある
INSERT INTO articles (title, body, created_at)
SELECT 'title ' || g, repeat('本文', 50), timestamptz '2026-01-01' + ((g / 2) * interval '1 second')
FROM generate_series(1, 1000000) g;
CREATE INDEX articles_created_id_idx ON articles (created_at DESC, id DESC);
VACUUM ANALYZE articles;

並び順(created_at DESC, id DESC)と同じ順のインデックスを作るのが、どちらの方式でも前提になります。

実測結果

Pythonからそれぞれの位置で EXPLAIN (ANALYZE) を7回実行し、Execution Timeの中央値を取りました(1ページ20件、並列処理なし)。cursor方式は、OFFSETと同じ20件が返ることを確認したうえで測っています。

ページ OFFSET offset方式 cursor方式
1 0 0.029 ms 0.013 ms
51 1,000 0.194 ms 0.015 ms
501 10,000 3.033 ms 0.026 ms
5,001 100,000 21.285 ms 0.019 ms
25,001 500,000 155.897 ms 0.016 ms
50,000 999,980 302.739 ms 0.019 ms

offset方式はOFFSETの大きさにほぼ比例して遅くなり、最後のページでは約300msかかりました。cursor方式は、1ページ目も最後のページも0.02ms前後で変わりません。時間は検証環境(データがメモリに載った状態)の値ですが、この「比例する/一定」という傾向は環境が変わっても同じです。

EXPLAINで見る理由

OFFSET 500000の実行計画です。

 Limit  (cost=67760.01..67762.72 rows=20 width=28) (actual time=203.581..203.589 rows=20 loops=1)
   Buffers: shared hit=1212 read=23435
   ->  Index Scan using articles_created_id_idx on articles  (cost=0.42..135520.95 rows=1000010 width=28) (actual time=0.040..181.363 rows=500020 loops=1)
         Buffers: shared hit=1212 read=23435
 Execution Time: 203.611 ms

インデックスは使われていますが、rows=500020 行を読んでいます。OFFSETは「飛ばす」といっても、実際には先頭から50万件を順に読んで捨てているためです。読んだページも約2.5万です。

同じ位置をcursor方式で取得した実行計画です。

 Limit  (cost=0.42..4.78 rows=20 width=28) (actual time=0.028..0.036 rows=20 loops=1)
   Buffers: shared hit=4 read=1
   ->  Index Scan using articles_created_id_idx on articles  (cost=0.42..108770.76 rows=499726 width=28) (actual time=0.028..0.033 rows=20 loops=1)
         Index Cond: (ROW(created_at, id) < ROW('2026-01-03 21:26:40+00'::timestamp with time zone, 500001))
         Buffers: shared hit=4 read=1
 Execution Time: 0.068 ms

Index Cond で前回の位置へ直接移動し、20行だけ読んで終わっています。読んだページは5つです。

OFFSETで起きる重複と欠落を再現する

OFFSETの位置は「何件目か」なので、ページをめくる間にデータが増えたり減ったりすると、位置がずれます。10件のお知らせを3件ずつ表示する小さなテーブルで再現しました。

途中で新しい行が追加された場合:重複

-- 1ページ目
SELECT id, title FROM notices ORDER BY created_at DESC, id DESC LIMIT 3 OFFSET 0;
--  10 | お知らせ10
--   9 | お知らせ9
--   8 | お知らせ8

-- (この間に新しいお知らせが2件追加される)

-- 2ページ目:OFFSET方式
SELECT id, title FROM notices ORDER BY created_at DESC, id DESC LIMIT 3 OFFSET 3;
--   9 | お知らせ9     ← 1ページ目で表示済み
--   8 | お知らせ8     ← 1ページ目で表示済み
--   7 | お知らせ7

-- 2ページ目:cursor方式(1ページ目の最後 = id 8)
SELECT id, title FROM notices
WHERE (created_at, id) < ('2026-09-01 08:00+00', 8)
ORDER BY created_at DESC, id DESC LIMIT 3;
--   7 | お知らせ7
--   6 | お知らせ6
--   5 | お知らせ5

途中で行が削除された場合:欠落

1ページ目(10・9・8)を表示した後に、id 10と9が削除された場合です。

-- 2ページ目:OFFSET方式
--   5 | お知らせ5
--   4 | お知らせ4
--   3 | お知らせ3     ← 7と6が一度も表示されない

-- 2ページ目:cursor方式(id 8の次から)
--   7 | お知らせ7
--   6 | お知らせ6
--   5 | お知らせ5

OFFSET方式では、追加で「同じ記事が2回出る」、削除で「一度も出ない記事がある」状態になりました。cursor方式は「id 8の次」という位置を覚えているので、どちらの場合も正しく続きを返しています。タイムラインや、全件を順に処理するバッチでは、この違いが大きく効きます。

複合カーソル:created_atだけでは足りない理由

cursorの条件に created_at だけを使うと、同じ時刻の行がページの境目にあったときに取りこぼします。検証用テーブルは1秒に2件なので、これを実際に確認できます。

-- 1ページ目(2件)
--  1000000 | 2026-01-06 18:53:20+00
--   999999 | 2026-01-06 18:53:19+00   ← 最後の行

-- × created_at だけで次のページ
SELECT id, created_at FROM articles
WHERE created_at < '2026-01-06 18:53:19+00' ORDER BY created_at DESC, id DESC LIMIT 2;
--   999997 | 2026-01-06 18:53:18+00
--   999996 | 2026-01-06 18:53:18+00      ← 999998 が抜けた

-- ○ (created_at, id) の組で次のページ
SELECT id, created_at FROM articles
WHERE (created_at, id) < ('2026-01-06 18:53:19+00', 999999) ORDER BY created_at DESC, id DESC LIMIT 2;
--   999998 | 2026-01-06 18:53:19+00
--   999997 | 2026-01-06 18:53:18+00

999999と同じ時刻の999998が、created_atだけの条件では消えてしまいました。並び順を一意に決めるため、最後に一意な列(id)を加えた組み合わせをカーソルにします。

行値比較とORの書き方で、実行計画が変わる

(created_at, id) < (...) は、次のORの条件と同じ意味です。

WHERE created_at < :c OR (created_at = :c AND id < :i)

ところが、同じ位置(50万件目の次)でORの書き方を実行すると、PostgreSQL 16では次のようになりました。

 Limit  (cost=0.42..1.94 rows=20 width=16) (actual time=44.614..44.618 rows=20 loops=1)
   ->  Index Only Scan using articles_created_id_idx on articles  (cost=0.42..37904.65 rows=499727 width=16) (actual time=44.612..44.615 rows=20 loops=1)
         Filter: ((created_at < '2026-01-03 21:26:40+00'::timestamp with time zone) OR ((created_at = '2026-01-03 21:26:40+00'::timestamp with time zone) AND (id < 500001)))
         Rows Removed by Filter: 500000
 Execution Time: 44.633 ms

条件が Index Cond ではなく Filter になり、50万件を読んで捨てていました(44.6ms)。行値比較の書き方では Index Cond になり0.068msだったので、PostgreSQLでは (a, b) < (x, y) の行値比較で書くのがおすすめです。行値比較は、並び順がすべて同じ向き(すべてDESC、またはすべてASC)のときに使えます。

前のページへ戻る

前のページは、比較と並び順を逆にして取得し、最後に並べ直します。

SELECT * FROM (
  SELECT id, created_at FROM articles
  WHERE (created_at, id) > ('2026-01-06 18:53:17+00', 999995)   -- 今のページの先頭の行
  ORDER BY created_at ASC, id ASC LIMIT 3
) t
ORDER BY created_at DESC, id DESC;
--   999998 / 999997 / 999996

実行計画は Index Only Scan Backward(インデックスを逆向きに読む)となり、0.023msでした。同じインデックスを逆向きに読めるので、前後どちらにも速く移動できます。

カーソルトークンを返すAPIの実装(Node.js)

APIでは、カーソルの中身(created_at, id)をそのまま見せず、JSONをBase64URLにした文字列(カーソルトークン) として返すのが一般的です。クライアントは中身を気にせず、次のリクエストにそのまま付けるだけで済みます。Node.js 22とpg 8.23で実装し、localhost:7030で動かして確認しました。

const http = require('node:http');
const { Pool } = require('pg');

const pool = new Pool({ host: '127.0.0.1', database: 't03', user: 't03app', password: '...' });
const MAX_LIMIT = 100;

function encodeCursor(row) {
  return Buffer.from(JSON.stringify({ c: row.created_at, i: String(row.id) })).toString('base64url');
}

function decodeCursor(token) {
  try {
    const { c, i } = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
    if (typeof c !== 'string' || Number.isNaN(Date.parse(c))) return null;
    if (typeof i !== 'string' || !/^\d+$/.test(i)) return null;
    return { createdAt: c, id: i };
  } catch {
    return null;
  }
}

async function listArticles(url) {
  const n = Number(url.searchParams.get('limit') ?? 20);
  const limit = Number.isInteger(n) ? Math.min(Math.max(n, 1), MAX_LIMIT) : 20;
  const token = url.searchParams.get('cursor');
  // created_at は文字列で受け取る(理由は後述)
  const cols = 'id, title, created_at::text AS created_at';

  let result;
  if (token) {
    const cur = decodeCursor(token);
    if (!cur) return { status: 400, body: { error: 'invalid cursor' } };
    result = await pool.query(
      `SELECT ${cols} FROM articles
       WHERE (created_at, id) < ($1::timestamptz, $2::bigint)
       ORDER BY articles.created_at DESC, id DESC LIMIT $3`,
      [cur.createdAt, cur.id, limit + 1]);
  } else {
    result = await pool.query(
      `SELECT ${cols} FROM articles ORDER BY articles.created_at DESC, id DESC LIMIT $1`,
      [limit + 1]);
  }
  const rows = result.rows;
  const hasNext = rows.length > limit;       // 1件多く取って「次があるか」を判定
  const items = rows.slice(0, limit);
  return { status: 200, body: { items, next_cursor: hasNext ? encodeCursor(items.at(-1)) : null } };
}

http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (url.pathname !== '/articles') return res.writeHead(404).end();
  try {
    const { status, body } = await listArticles(url);
    res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
  } catch (e) {
    console.error(e);   // 詳細はログにだけ出す
    res.writeHead(500, { 'Content-Type': 'application/json' }).end('{"error":"internal error"}');
  }
}).listen(7030);

ポイントは3つです。

  • limit + 1 件取得して、次のページがあるかを判定します。COUNTを使わずに next_cursor を null にできます
  • カーソルの中身を検証し、壊れていれば400を返します。値はプレースホルダーで渡します
  • limit に上限(MAX_LIMIT)を設けます

curlで確認

curl -s 'http://localhost:7030/articles?limit=2'

実行結果:

{"items":[{"id":"1000000","title":"title 1000000","created_at":"2026-01-06 18:53:20+00"},{"id":"999999","title":"title 999999","created_at":"2026-01-06 18:53:19+00"}],"next_cursor":"eyJjIjoiMjAyNi0wMS0wNiAxODo1MzoxOSswMCIsImkiOiI5OTk5OTkifQ"}

トークンをデコードすると {"c":"2026-01-06 18:53:19+00","i":"999999"} です。このトークンを ?cursor= に付けて次のページを取得すると、同じ時刻の999998から正しく続きが返りました。

{"items":[{"id":"999998","title":"title 999998","created_at":"2026-01-06 18:53:19+00"},{"id":"999997","title":"title 999997","created_at":"2026-01-06 18:53:18+00"}],"next_cursor":"eyJjIjoiMjAyNi0wMS0wNiAxODo1MzoxOCswMCIsImkiOiI5OTk5OTcifQ"}
確認したこと 結果
壊れたトークン(cursor=abc) {"error":"invalid cursor"} HTTP 400
最後のページ 残りの2件と "next_cursor":null
50万件目付近のページ 0.0016〜0.0024秒(curlの time_total、3回)

注意:JavaScriptのDateはマイクロ秒を切り捨てる

PostgreSQLのtimestamptzはマイクロ秒(6桁)まで持ちますが、pgが返すJavaScriptのDateはミリ秒(3桁)までです。確認すると次のようになりました。

Date で受け取る : 2026-09-01T10:00:00.123Z
::text で受け取る: 2026-09-01 10:00:00.123456+00

now() で登録した行のように、マイクロ秒を持つ時刻をDateのままカーソルにすると、切り捨てた分だけ位置がずれ、同じ時刻付近の行を取りこぼす可能性があります。検証用テーブルは秒単位の時刻だったため問題は出ませんでしたが、上のコードのように created_at::text で文字列として受け取り、そのままトークンに入れると安全です。

総件数(COUNT)の扱い

「全12,345件」のような表示には COUNT(*) が必要ですが、件数が多いとCOUNT自体が重くなります。100万件で実行すると149.311msでした。ページ本体(0.02ms)よりはるかに重い処理です。

方法 内容 向いている場面
総件数を出さない 「もっと見る」「次へ」だけにする 無限スクロール、タイムライン
limit + 1 件取得 次があるかだけを判定 cursor方式のAPI全般
上限付きで数える SELECT count(*) FROM (SELECT 1 FROM ... LIMIT 1001) t で「1,000件以上」と表示 検索結果の件数表示
統計情報の概算 pg_class.reltuples(検証では1000010) 「約100万件」でよい画面
キャッシュ 数分ごとに数えて保存 ダッシュボード

cursor方式では、総件数やページ番号を前提にしない画面設計にすると、方式の良さを活かせます。

使い分けの判断表

条件 おすすめ
件数が数千件程度まで、ページ番号で移動したい offset方式
管理画面で「〇ページ目」を指定したい offset方式(深いページは絞り込みで誘導)
無限スクロール、タイムライン cursor方式
外部に公開するAPI cursor方式(トークンで返す)
バッチで全件を順に処理する cursor方式(重複・欠落を防ぐ)
データの追加・削除が頻繁 cursor方式
並び順を利用者が自由に変えられる 並び順ごとに複合インデックスとカーソルが必要。候補を絞る

offset方式もcursor方式も、それぞれ得意な場面がある方式です。件数と画面の使われ方から選び、深いページまで使われるならcursor方式に切り替える、という判断がしやすくなります。

よくある質問

Q. OFFSETでもインデックスがあれば速いのではないですか? A. インデックスがあっても、飛ばす行を先頭から順に読む必要があります。実測でもIndex Scanのまま、OFFSET 50万件で rows=500020 を読み、約200msかかりました。インデックスは「並べ替えを省く」効果はありますが、「読み飛ばす量」は減らせません。

Q. cursor方式でページ番号を表示できますか? A. 基本的にはできません。「前へ」「次へ」の移動に向いた方式です。ページ番号が必要な画面ではoffset方式を使い、深いページは検索条件で絞り込んでもらう設計にすると扱いやすくなります。

Q. カーソルトークンは暗号化が必要ですか? A. Base64は暗号化ではなく、誰でもデコードできます。中身が並び順の値だけなら問題になりにくいですが、改ざんされても困らないよう、サーバー側で必ず検証します。改ざんを検知したい場合は、HMACの署名を付ける方法があります。

Q. 並び順にidだけを使えばカーソルは簡単になりますか? A. 連番のidで新しい順に並べるだけなら、WHERE id < :last_id ORDER BY id DESC の単一カーソルで済みます。作成日時など一意でない列で並べる場合に、idを加えた複合カーソルが必要になります。

まとめ

  • offset方式は「何件飛ばすか」、cursor方式は「どの行の次か」でページを指定する
  • 100万件の実測で、offsetは深いページで302msまで遅くなり、cursorは0.02ms前後で一定だった
  • OFFSETは、途中の追加で重複、削除で欠落が起きる。cursorは位置を覚えているので起きにくい
  • カーソルは (created_at, id) の複合にし、PostgreSQLでは行値比較で書くと Index Cond になる
  • APIではBase64URLのトークンで返し、limit + 1件で次の有無を判定、トークンは必ず検証する
  • 総件数のCOUNTは重い。「もっと見る」や上限付きカウントで画面を設計する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ページネーションのoffsetとcursorの違い|100万件で実測比較」

株式会社RJCでは、データが増えても快適に使えるシステムを目指して、設計の選択肢を仲間と一緒に検証しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?