はじめに
APIやDBの一覧取得において「ページネーションをどう実装するか」は、技術面接でも実務でも頻出のテーマです。本記事では、カーソル(キーセット)方式とLIMIT/OFFSET方式の違い、それぞれのメリット・デメリット、各言語(Python / Go / TypeScript)でのコード例、そして「そもそも大量データ一覧のUIをどう設計すべきか」というより上位の論点までをまとめます。
1. 基本的な仕組みの違い(図解)
OFFSET方式は「不要な行も含めて数えてから捨てる」ため、ページが深くなるほど無駄なスキャンが増えます。カーソル方式は「インデックスで目的の位置に一発でジャンプする」ため、ページが深くなっても速度がほぼ一定です。
SQLの違い
-- OFFSET方式:3ページ目(1ページ20件)を取得
SELECT * FROM articles
ORDER BY id ASC
LIMIT 20 OFFSET 40;
-- カーソル方式:前回最後に取得したid=40より後ろを取得
SELECT * FROM articles
WHERE id > 40
ORDER BY id ASC
LIMIT 20;
2. 性能・整合性の比較
| 観点 | LIMIT/OFFSET | カーソル(キーセット) |
|---|---|---|
| 任意ページへのジャンプ | できる | できない(前後移動のみ) |
| 大規模データでの性能 | ページが深いほど劣化 | 常にほぼ一定で高速 |
| データ変動時の安定性 | 重複・欠落が起きやすい | ズレが起きにくい |
| 実装の難易度 | 低い | やや高い(複合ソートキー設計が必要) |
| 全体ページ数の把握 | 容易 | 困難(COUNTが別途必要) |
| 向いているユースケース | 管理画面、小〜中規模一覧 | 無限スクロール、大規模データ、更新頻度の高いフィード |
なぜOFFSETはズレるのか(図解)
id=2が削除されると後続レコードが1つ前にズレるため、OFFSET=3で取得すると本来2ページ目に出るはずだったid=4を読み飛ばしてしまいます。カーソル方式は「id > 3」のように絶対値で条件を張るため、この種のズレが起きません。
3. カーソル方式のデメリット
- 任意ページへのジャンプができない(「3ページ目」に飛べない)
-
総件数の取得が難しい(別途
COUNT(*)が必要でコストがかかる) - 一意で不変なソートキーが必須(複数のソート順をサポートするには複合インデックスが増え、書き込み性能とのトレードオフが生じる)
- 実装が複雑(ASC/DESCで不等号の向きが変わる、複合キーのタイブレーク処理、カーソル値のopaque化など)
4. コード例
4-1. Python(Django REST Framework)
DRFはCursorPaginationを標準搭載しており、orderingを指定するだけでopaqueなカーソルを自動生成してくれます。
from rest_framework.pagination import CursorPagination
from rest_framework.generics import ListAPIView
class ArticleCursorPagination(CursorPagination):
page_size = 20
ordering = "-created_at" # 降順ソートキー。一意性を担保するため通常はid等を併用する
class ArticleListView(ListAPIView):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
pagination_class = ArticleCursorPagination
レスポンス例:
{
"next": "https://api.example.com/articles/?cursor=cD0yMDI2LTA4LTA2",
"previous": null,
"results": [ { "id": 101, "title": "...", "created_at": "2026-08-06" } ]
}
4-2. Python(SQLAlchemy + sqlakeyset)
from sqlakeyset import get_page
from sqlalchemy import select
query = select(Article).order_by(Article.created_at.desc(), Article.id.desc())
# 初回ページ
page = get_page(query, per_page=20, session=session)
# 次ページ(page.paging.bookmark_next がカーソルとして機能)
next_page = get_page(query, per_page=20, page=page.paging.next, session=session)
4-3. Go(キーセットページネーションの手動実装)
type Cursor struct {
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
}
func FetchArticles(db *sql.DB, cursor *Cursor, limit int) ([]Article, *Cursor, error) {
query := `
SELECT id, title, created_at FROM articles
WHERE ($1::timestamp IS NULL)
OR (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
`
var createdAt interface{}
var id interface{}
if cursor != nil {
createdAt, id = cursor.CreatedAt, cursor.ID
}
rows, err := db.Query(query, createdAt, id, limit)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
rows.Scan(&a.ID, &a.Title, &a.CreatedAt)
articles = append(articles, a)
}
var next *Cursor
if len(articles) == limit {
last := articles[len(articles)-1]
next = &Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
}
return articles, next, nil
}
複合キー(created_at, id)をタプル比較しているのがポイントです。同じcreated_atのレコードが複数あってもid順で一意に並べ替えられるため、重複や欠落を防げます。
4-4. TypeScript(Prisma)
// 初回取得
const firstPage = await prisma.article.findMany({
take: 20,
orderBy: { id: "desc" },
});
// 次ページ:前回最後のレコードのidをcursorに指定
const lastId = firstPage[firstPage.length - 1].id;
const nextPage = await prisma.article.findMany({
take: 20,
skip: 1, // cursor自体は結果に含めないためskip:1が必要
cursor: { id: lastId },
orderBy: { id: "desc" },
});
4-5. TypeScript(GraphQL Relay Connection形式)
type Connection<T> = {
edges: { node: T; cursor: string }[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function getArticleConnection(
after: string | null,
first: number
): Promise<Connection<Article>> {
const cursorId = after ? decodeCursor(after) : undefined;
const rows = await prisma.article.findMany({
take: first + 1, // 次ページの有無を判定するため1件多く取る
...(cursorId && { cursor: { id: cursorId }, skip: 1 }),
orderBy: { id: "desc" },
});
const hasNextPage = rows.length > first;
const nodes = hasNextPage ? rows.slice(0, -1) : rows;
return {
edges: nodes.map((n) => ({ node: n, cursor: encodeCursor(n.id) })),
pageInfo: {
hasNextPage,
endCursor: nodes.length ? encodeCursor(nodes[nodes.length - 1].id) : null,
},
};
}
limit + 1件取得して「次があるかどうか」を判定するのは、カーソル実装の定番テクニックです。
5. 各言語のライブラリまとめ
| 言語 | ライブラリ/機能 | 特徴 |
|---|---|---|
| Python | Django REST Framework CursorPagination
|
組み込み。ordering指定だけで使える |
| Python |
sqlakeyset(SQLAlchemy用) |
ORM/Core両対応のoffset-freeページング |
| Go | nrfta/paging-go |
高性能キーセット実装。前方ページングのみ対応 |
| Go | rvflash/cursor |
軽量なカーソル参照点ライブラリ |
| Go | mickamy/go-keyset |
ORM非依存の型安全Keyset実装 |
| TypeScript | Prisma cursor + take/skip
|
ネイティブAPIで簡潔に実装可能 |
| TypeScript | typeorm-cursor-pagination |
TypeORM用Paginatorクラス |
| TypeScript | GraphQL Relay Connection仕様 |
edges/pageInfo/cursorの標準パターン |
6. 大量データ一覧のUI設計(図解)
「ユーザーは大量データを一度に把握できない」という前提に立つと、ページネーション方式の選定より上位に、情報設計の層があります。
生データをそのままテーブルに流し込むのではなく、サーバー側で絞り込んだ上で要約ビューを提示し、必要な時だけ詳細に潜れる構造にすることで、認知負荷とパフォーマンスの両方を抑えられます。
データ量に応じた実装方針
| データ量 | 推奨アプローチ |
|---|---|
| 〜200行 | 通常レンダリングで十分 |
| 200〜2,000行 | 仮想スクロールかページネーションをUXに応じて選択 |
| 2,000行以上 | 仮想スクロール+サーバー側フィルタ/ソートが必須 |
| 10,000行以上 | 仮想スクロール(react-window等)+ページング/無限スクロールの併用、60FPS維持が目標 |
フロントエンド実装例(React + react-window + 無限スクロール)
import { FixedSizeList as List } from "react-window";
import { useInfiniteQuery } from "@tanstack/react-query";
function ArticleList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["articles"],
queryFn: ({ pageParam }) => fetchArticles(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const rows = data?.pages.flatMap((p) => p.items) ?? [];
return (
<List
height={600}
width="100%"
itemCount={rows.length}
itemSize={48}
onItemsRendered={({ visibleStopIndex }) => {
if (visibleStopIndex >= rows.length - 5 && hasNextPage) {
fetchNextPage();
}
}}
>
{({ index, style }) => <div style={style}>{rows[index].title}</div>}
</List>
);
}
react-windowで見えている行だけを描画し(仮想化)、react-queryのuseInfiniteQueryでカーソルベースの追加取得を行うことで、数万件規模でも滑らかにスクロールできる一覧を実装できます。
まとめ
- OFFSET方式は実装が簡単でページジャンプもできるが、大規模データでは性能が劣化しやすくデータのズレも起きやすい。
- カーソル方式は大規模・高頻度更新データで安定した性能を出せるが、ページ番号ジャンプや総件数取得ができない、実装が複雑というトレードオフがある。
- 各言語にはカーソルページネーションの定型処理を肩代わりしてくれるライブラリが存在し、複合キーの比較やopaque化などの面倒な部分を任せられる。
- ページネーション方式の選定はあくまで手段であり、本質は「ユーザーが本当に必要な粒度の情報を、どう認知負荷なく届けるか」という設計問題である。サーバー側フィルタ・要約ビュー・仮想化を組み合わせることで、パフォーマンスとUXを両立できる。