Claude Codeでカーソルページネーションを実装する手順 — offsetからの移行
offsetページネーションを(created_at, id)のカーソル方式へ移す手順です。同時刻の行・挿入・削除を試験するテストの書かせ方と、Claude Codeへの指示例を示します。
自社APIの一覧エンドポイントが?page=3&per_page=20の形なら、カーソル方式への移行で効くのは速度よりも結果の正しさです。offsetは「前のページで見た行数」を数えて飛ばす仕組みなので、ページを送る間に行が増減すると重複や取りこぼしが出ます。
この記事では、一覧APIを(created_at, id)の複合キーによるカーソル方式へ移す作業を、Claude Codeに進めさせる手順で示します。要点は実装そのものより、同時刻の行・ページ間の挿入・カーソル行の削除をテストで先に固定することです。
offsetの何が問題で、カーソルは何を変えるのか
offsetはデータベースに「先頭からN行を捨てろ」と伝えるだけで、文脈を持ちません。SQL標準の定義は、行を並べ替えてから先頭の指定行数を落とす、というものです。DBMSは捨てる行も読んで順序に並べる必要があるため、offsetが大きいほど仕事が増えます。
もう一つの問題が結果のずれです。use-the-index-lukeの解説では、1ページ目を取った後に新しい行が挿入されると、次のページで同じ行が重複して返る例を挙げています。「見た行数で飛ばす」発想そのものが誤りだ、というのが同記事の立場です。
同じ仕組みから、見たことのある行が削除された場合は逆向きのずれが起きます。1ページ目の行が消えると、offsetの位置が1行ぶん前へ詰まり、次のページの先頭1行が飛ばされます。これはoffsetの定義から導ける帰結で、移行前にテストで再現しておくと「何が直ったか」を示せます。
カーソル方式(seek method / keyset pagination)は、数ではなく「最後に見た行の値」を渡します。次のページは「その行より後ろ」を条件に選ぶだけです。
| 観点 | offset | カーソル |
|---|---|---|
| 次ページの指定 | offset飛ばす行数 | カーソル最後に見た行のキー値 |
| 大きいページ番号 | offset読み飛ばす行ぶん遅くなる | カーソル条件で位置を直接引く |
| ページ間の挿入 | offset重複が出ることがある | カーソル影響されにくい |
| 任意のページへの移動 | offsetできる | カーソルできない |
| 前のページへ戻る | offset簡単 | カーソル比較と並び順を反転する |
任意のページへ飛べない点は代償です。同記事は、ページ番号を押させるUIは貧弱なナビゲーションだとしたうえで、無限スクロールなら問題にならないと述べています。管理画面で「5ページ目へ移動」を求められるAPIは、先に要件を確認します。
ソート順を決定的にする — (created_at, id)
カーソル方式の前提は、並び順が一意に決まることです。ORDER BY created_at DESCだけでは、同じcreated_atの行の順序をDBが保証しません。use-the-index-lukeは、並列実行のせいで同じプランでも毎回違う順で返ることがあると説明しています。
そこで一意な列を並び順の末尾に足します。主キーのidが定番です。
CREATE INDEX idx_posts_created_id ON posts (created_at, id);
SELECT *
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;WHERE (created_at, id) < ($1, $2)は行値(row value)の比較です。左から順に比べ、前の列が等しい間だけ次の列を見るので、「並び順で手前にある行」という意味になります。PostgreSQLの行値比較も同じ左から右の規則で、(created_at, id)に複合インデックスがあればインデックスを使えると、use-the-index-lukeは書いています。
LIMITを21にしているのは、1ページ20件に1件足して次ページの有無を判定するためです。21件目は返さず、hasMoreの判定にだけ使います。
行値比較が使えないデータベース
行値の範囲比較は、どのDBでも同じに動くわけではありません。use-the-index-lukeの対応表では、MySQLは式を正しく評価するものの、インデックスのアクセス条件には使えないとされています。Oracleは範囲演算子を行値に適用できず、SQL Server 2017は行値自体を扱えません。
これらでは同じ意味を展開して書きます。
WHERE created_at <= $1
AND (created_at < $1 OR (created_at = $1 AND id < $2))先頭のcreated_at <= $1は冗長に見えます。ただ、オプティマイザがOR条件から共通部分を括り出してくれないため、これを足してインデックスのアクセス条件にします。誰かが「不要な条件」と判断して消すと遅くなるので、コメントで理由を残す運用が要ります。
NULLが入る列は使えない
PostgreSQLの行値比較は、比較の途中でNULLに当たると結果が不明(NULL)になり、行が条件に合わないものとして落ちます。ドキュメントの例ではROW(1,2,NULL) < ROW(1,3,0)は、3組目まで見ないのでtrueです。しかし並び順の列にNULLが混じれば、取りこぼしの原因になります。カーソルに使う列はNOT NULLにするか、別の列を選びます。
Claude Codeに移行を進めさせる
作業は「テストを先に固定する」「計画を確認する」「小さく実装する」の順に分けます。公式ドキュメントのリファクタリングの項は、変更を小さく、テストできる単位で進めるよう勧めています。
offsetからの移行の進め方
- 1
CLAUDE.mdに検証コマンドを書く
テストの実行コマンドを書いておくと、Claude Codeは変更のたびに自分で結果を確かめられます。公式のベストプラクティスも、合否が返るチェックを渡すことを勧めています。
- 2
今のoffset挙動をテストで固定する
ずれの再現テストを先に書かせます。移行前に失敗するテストがあれば、移行後に通ったことが成果の証拠になります。
- 3
プランモードで設計を確認する
編集前に計画だけを出させ、カーソルの形式・互換期間・エラーの扱いを人が決めます。
- 4
実装してテストを回す
実装を任せ、テストの出力まで読ませます。
CLAUDE.mdに置く内容
検証コマンドと、ページネーションの規約を短く書きます。次は例です(コマンドは自分のプロジェクトに合わせます)。
# ページネーション規約
- 一覧APIはカーソル方式。並び順は created_at DESC, id DESC
- カーソルは created_at(DBの文字列表現のまま)と id を base64url で包む
- カーソルをクライアントが解釈する前提にしない(不透明な文字列)
- テスト実行: pnpm vitest run src/posts
- DB統合テストには DATABASE_URL が必要(.env.example 参照)公式のベストプラクティスは、CLAUDE.mdには「Claudeがコードを読むだけでは分からないこと」だけを書き、テストのコマンドもその一つに挙げています。
失敗するテストから始める
1つ目の依頼は、実装ではなく再現テストです。ベストプラクティスの「失敗するテストを書いてから直す」型に沿います。
GET /posts は今 page と per_page で offset ページングしている。
次の2つの不具合を再現する統合テストを書いて、現状で落ちることを
確認して。実装はまだ変えないで。
1. 1ページ目を取得した後に最新の行を1件挿入し、2ページ目を取ると
1ページ目の行が重複する
2. 1ページ目を取得した後にその中の行を1件削除し、2ページ目を
取ると、本来2ページ目の先頭の行が抜ける挿入と削除のどちらも、実際にテストが落ちることを確認させる点が要です。落ちないなら、テストが不具合を捉えていないか、前提の理解が違っています。
プランモードで設計を詰める
次に計画だけを出させます。プランモードは、承認するまでClaudeがファイルを編集しない動作です。起動はclaude --permission-mode planで、セッション中はShift+Tabでも切り替えられます。
claude --permission-mode planGET /posts を (created_at, id) のカーソル方式に移したい。
変更するファイル、カーソルの形式、既存クライアント向けの互換期間
(page パラメータの扱い)、不正なカーソルを受けたときの応答を
計画にまとめて。実装はしない。計画で人が決めるのは次の3点です。
- 互換期間:
pageとcursorを一定期間は両方受けるのか、新バージョンのパスを切るのか - カーソルの中身: 後述のとおり、時刻は丸めずに持たせる
- 不正カーソルの応答: 400にして、原因が分かるエラーコードを返す
カーソルの実装
エンコードは、created_atとidをJSONにしてbase64urlで包む形にします。クライアントには中身を見せない不透明な文字列として扱わせます。MCPのページネーション仕様も、カーソルを不透明な文字列とする設計です。詳しくはMCPのページネーション仕様にまとめています。
// src/pagination/cursor.ts
export type Cursor = { createdAt: string; id: string };
export class InvalidCursorError extends Error {}
export function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
export function decodeCursor(raw: string): Cursor {
try {
const v = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
const ok =
typeof v.createdAt === "string" &&
!Number.isNaN(Date.parse(v.createdAt)) &&
typeof v.id === "string";
if (!ok) throw new Error("shape");
return { createdAt: v.createdAt, id: v.id };
} catch {
throw new InvalidCursorError("invalid cursor");
}
}次の一覧取得のクエリ側は、limit + 1件を取って余りをhasMoreに変換します。
// src/posts/list.ts
export async function listPosts(db: Db, limit: number, raw?: string) {
const c = raw ? decodeCursor(raw) : null;
const { rows } = await db.query(
`SELECT id::text, created_at::text AS created_at, title
FROM posts
WHERE ($1::timestamptz IS NULL
OR (created_at, id) < ($1::timestamptz, $2::bigint))
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[c?.createdAt ?? null, c?.id ?? null, limit + 1],
);
const page = rows.slice(0, limit);
const last = page[page.length - 1];
return {
items: page,
nextCursor:
rows.length > limit
? encodeCursor({ createdAt: last.created_at, id: last.id })
: null,
};
}これは構成を示す例で、Db型・テーブル定義・idの型(ここではbigint)は自分のプロジェクトに読み替えます。
時刻をDateに変換しない
最も踏みやすい落とし穴は、created_atをアプリ側の日時型に変換してからカーソルへ入れることです。PostgreSQLのtimestamptzの分解能は1マイクロ秒です。ミリ秒精度の型に丸めると、DBに残る値と一致しなくなります。
丸めた値で(created_at, id) < (丸めた値, id)と比べれば、同じ丸め後の時刻を持つ行が条件の境で重複したり抜けたりします。上のコードでcreated_at::textとしているのは、DBが返した文字列をそのまま持ち回るためです。
試験する — 同時刻・挿入・削除
実装より時間をかけるのがテストです。公式ドキュメントも、テストの依頼では検証したい挙動を具体的に書くよう勧めています。Claude Codeには「エッジ条件のテストケースを足して」と頼む形が紹介されており、ここでは条件を3つに分けて指定します。
listPosts に対して次の統合テストを src/posts/list.test.ts に追加して。
実DBを使い、テストごとにテーブルを空にすること。
1. 同時刻: created_at が完全に同じ行を5件入れ、limit=2 で最後まで
辿ったとき、全5件がちょうど1回ずつ、id降順で返る
2. 挿入: 1ページ目を取った後に、より新しい行を1件挿入しても、
2ページ目以降に重複が出ず、取りこぼしもない
3. 削除: 1ページ目の最後の行(カーソルが指す行)を削除してから
カーソルを渡しても、続きのページが正しく返る
追加したら実行して、失敗があれば直して。同時刻の行を並べるテスト
同時刻の行のテストが、複合キーの価値を直接示します。created_atだけで比較する実装なら、境界の時刻に当たった行はまとめて落ちるか、重複します。テストではcreated_atを固定値にしてidだけを変えます。
it("同時刻の行を重複も欠落もなく返す", async () => {
const t = "2026-10-01 12:00:00.123456+00";
for (let i = 0; i < 5; i++) {
await db.query(
"INSERT INTO posts (created_at, title) VALUES ($1, $2)",
[t, `p${i}`],
);
}
const seen: string[] = [];
let cursor: string | undefined;
do {
const r = await listPosts(db, 2, cursor);
seen.push(...r.items.map((x) => x.id));
cursor = r.nextCursor ?? undefined;
} while (cursor);
expect(new Set(seen).size).toBe(5);
expect(seen).toEqual([...seen].sort((a, b) => Number(b) - Number(a)));
});マイクロ秒まで指定しているのは、前節の丸めの落とし穴も同時に捉えるためです。
挿入と削除のテスト
挿入のテストは、1ページ目を取得した直後に新しい行を足します。降順で新しい行が先頭側に入るので、カーソルより後ろの範囲は変わらず、2ページ目以降に重複は出ません。新しい行は次回の1ページ目から見えます。
削除のテストは、カーソルが指す行そのものを消します。カーソルは行への参照ではなく、値の組を持つだけです。そのため該当行が消えても、比較条件はそのまま成り立ちます。「カーソルが指す行が存在しないとエラーになる」実装(行IDで引き直す設計)に変えたとき、このテストが落ちます。
| テスト | 検出する誤り |
|---|---|
| 同時刻の行 | 検出する誤りidを並び順に入れ忘れる / <=の使い間違い |
| 新しい行の挿入 | 検出する誤り先頭にずれるoffset的な処理 |
| カーソル行の削除 | 検出する誤り行IDで引き直す設計 |
| マイクロ秒の時刻 | 検出する誤り時刻の丸め |
| 不正なカーソル | 検出する誤り例外がそのまま500になる |
不正なカーソルを400にする
クライアントが壊れたカーソルを送る場合も試験します。InvalidCursorErrorを捕まえて400を返すハンドラを書き、テストで確かめます。
不正な cursor(base64でない文字列、JSONでない中身、id が欠けた
オブジェクト)を渡したら 400 と code: "invalid_cursor" を返す
テストを追加して。作業の終わりを機械に判定させる
「テストが通りました」の報告だけでは、通ったかどうかをその場で確かめられません。ベストプラクティスは、Claudeにコマンドとその出力を示させるよう勧めています。
長い作業なら、完了条件を/goalで渡す手もあります。公式の例は/goal all tests in tests/auth pass and lint is cleanの形で、別のモデルが毎ターン条件を判定し、満たされるまで続けさせます。
/goal src/posts のテストがすべて通り、型チェックと lint にエラーがない最後に、新しいサブエージェントでdiffを見直させる方法もあります。
use a subagent to review the diff against the plan, check that the
three edge cases have tests, and report only correctness gapsベストプラクティスは、レビューを頼むときはskip styleのように範囲を絞るよう勧めています。指摘が無いと失敗に見えるため、レビュアーは必ず何かを見つけるからです。
移行で見落としやすい点
- ソート条件の追加:
?sort=titleのような並び替えを許す一覧は、並び替えの列ごとにカーソルの中身とインデックスが変わります。並び替えの種類は最初に絞ります - 逆方向のページ送り: 前のページへ戻るには、比較と並び順の両方を反転します。逆向きの需要が無いなら実装しません
- 既存クライアントの
page: 計画で決めた互換期間のあいだはpageを受け、終了日を決めてから廃止します - 件数の表示: 総件数を出すには別の
COUNTが要ります。カーソルは件数を知る手段ではありません
カーソル方式を採用している実例は、同じ設計でも指定方法が少しずつ違います。たとえばCompliance APIのActivity Feedは、新しい順に返し、created_atが同じ行をIDで順序付けしています。Activity Feedのカーソルページネーションの解説が参考になります。
SDK側でページネーションを扱う実装はRubyのSDKの記事に、バックエンドをClaude Codeで組み立てる流れはFastAPIバックエンドの構築の記事にあります。
まとめ
offsetからの移行で押さえる点は、並び順の末尾に一意な列を足すこと、カーソルに時刻を丸めずに持たせること、この2点を同時刻・挿入・削除のテストで固定することです。Claude Codeには、再現テスト、プランでの設計確認、小さな実装、出力つきの検証の順で渡すと、「通ったように見える」実装を避けられます。
任意のページへ飛ぶ機能を残したい一覧は、無理にカーソルへ寄せず、件数の少ない画面だけoffsetを残す判断もあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。