- SQL
SQL LIMIT OFFSETの使い方|上位n件取得とDB別構文の早見表
LIMITとは、SELECTの結果から取り出す行数の上限を決める句で、OFFSETとは、先頭から読み飛ばす行数を決める句です。 ORDER BY score DESC LIMIT 3 と書けば「点数の高い順に上位3件」、LIMIT 3 OFFSET 2 と書けば「3件目から3件」を取り出せます。ただしLIMITは標準SQLの書き方ではなく、MySQL・PostgreSQL・SQLiteでは使えますが、SQL ServerとOracleでは TOP や FETCH FIRST という別の書き方になります。
この記事では、同じサンプル表に対してMySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1で実際にSQLを流し、その出力をそのまま載せています。5つのDBの構文を1枚の対応表にまとめたうえで、ORDER BYを省くと結果が変わる例、同順位の行を取りこぼさないWITH TIES、深いOFFSETが遅くなる理由と書き換え方まで、コピペできる形で順に説明します。SQL ServerとOracleは手元に環境がないため未実行で、公式ドキュメントで確認できた範囲を「未実行」と明記して載せています。
LIMIT・OFFSETの早見表
まずは「やりたいこと」から書き方を引ける表です。n は取り出す件数、m は読み飛ばす件数です。
| やりたいこと | MySQL | PostgreSQL | SQLite | SQL Server | Oracle |
|---|---|---|---|---|---|
| 上位n件 | LIMIT n |
LIMIT n / FETCH FIRST n ROWS ONLY |
LIMIT n |
SELECT TOP (n) ... |
FETCH FIRST n ROWS ONLY |
| m件飛ばしてn件 | LIMIT n OFFSET m / LIMIT m, n |
LIMIT n OFFSET m / OFFSET m ROWS FETCH NEXT n ROWS ONLY |
LIMIT n OFFSET m / LIMIT m, n |
OFFSET m ROWS FETCH NEXT n ROWS ONLY |
OFFSET m ROWS FETCH NEXT n ROWS ONLY |
| m件飛ばして残り全部 | LIMIT m, 18446744073709551615 |
OFFSET m / LIMIT ALL OFFSET m |
LIMIT -1 OFFSET m |
OFFSET m ROWS |
OFFSET m ROWS |
| 同順位も含めて上位n件 | RANK() で絞る |
FETCH FIRST n ROWS WITH TIES |
RANK() で絞る |
TOP (n) WITH TIES |
FETCH FIRST n ROWS WITH TIES |
| 行番号で範囲指定 | ROW_NUMBER() |
ROW_NUMBER() |
ROW_NUMBER() |
ROW_NUMBER() |
ROW_NUMBER() / ROWNUM |
| UPDATE・DELETEで件数を絞る | ... ORDER BY ... LIMIT n |
サブクエリで主キーを絞る | サブクエリで主キーを絞る | サブクエリで主キーを絞る | サブクエリで主キーを絞る |
どの書き方を選ぶかの目安は次のとおりです。
| 状況 | 選ぶ書き方 | 理由 |
|---|---|---|
| MySQL・PostgreSQL・SQLiteだけで使う | LIMIT n OFFSET m |
3つとも同じ書き方で動く(この記事で確認) |
| SQL Server・Oracle・PostgreSQLで揃えたい | OFFSET m ROWS FETCH NEXT n ROWS ONLY |
標準SQLの書き方で、3つとも対応している |
| 5つすべてで同じSQLにしたい | ROW_NUMBER() をサブクエリで絞る |
ウィンドウ関数は5つすべてにある |
| 何十万件目といった深い位置を取りたい | キーセット方式(WHERE id > 前回の最後) |
読み飛ばす行を作らないので位置に関係なく速い |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL LIMIT OFFSETの使い方|上位n件取得とDB別構文の早見表」
確認に使った環境とサンプル表
この記事の実行結果は、次の環境で取得しました。
| DB | バージョン | 実行したか |
|---|---|---|
| MySQL | 8.0.46 | 実行した |
| PostgreSQL | 16.15 | 実行した |
| SQLite | 3.45.1 | 実行した |
| SQL Server | ― | 未実行(Microsoft Learnの ORDER BY句・TOP のページを開いて構文を確認) |
| Oracle Database | ― | 未実行(Oracle Database 19c SQL言語リファレンスの SELECT・ROWNUM のページを開いて構文を確認) |
サンプル表は、7人分の点数が入った scores 表です。85点が2人、70点が2人いる(同順位がある) のがポイントで、あとで同順位の扱いを見るときに使います。次のSQLは3つのDBすべてでそのまま動きます。
DROP TABLE IF EXISTS scores;
CREATE TABLE scores (id INT PRIMARY KEY, name VARCHAR(20), score INT);
INSERT INTO scores VALUES
(1,'Aoki',90),(2,'Baba',85),(3,'Chiba',85),(4,'Doi',70),
(5,'Endo',95),(6,'Fujii',70),(7,'Goto',60);
点数の高い順(同点はid順)に並べると、次の順番になります。以降の説明では、この並びの「何番目」を取り出すかを考えます。
| 順番 | id | name | score |
|---|---|---|---|
| 1 | 5 | Endo | 95 |
| 2 | 1 | Aoki | 90 |
| 3 | 2 | Baba | 85 |
| 4 | 3 | Chiba | 85 |
| 5 | 4 | Doi | 70 |
| 6 | 6 | Fujii | 70 |
| 7 | 7 | Goto | 60 |
上位n件を取り出す(LIMIT n)
上位n件は「ORDER BYで並べてからLIMITで切る」の2段構えで書きます。 LIMITはORDER BYのあと、SQL文のいちばん最後に書きます。
SELECT id, name, score
FROM scores
ORDER BY score DESC, id
LIMIT 3;
MySQL 8.0.46 の実行結果です。
+----+------+-------+
| id | name | score |
+----+------+-------+
| 5 | Endo | 95 |
| 1 | Aoki | 90 |
| 2 | Baba | 85 |
+----+------+-------+
PostgreSQL 16.15 と SQLite 3.45.1 でも、同じSQLで同じ3行(id 5、1、2)が返りました。
id | name | score
----+------+-------
5 | Endo | 95
1 | Aoki | 90
2 | Baba | 85
(3 rows)
ORDER BY score DESC, id の , id は、同点のときの並びを決めるための指定です。これを付ける理由は「同順位の扱い」の節で実行結果とともに説明します。
m件目からn件を取り出す(LIMIT n OFFSET m)
OFFSETは「読み飛ばす件数」です。「3件目から」取りたいときは、飛ばすのは2件なので OFFSET 2 と書きます。 「何件目から」と「OFFSETの値」が1つずれる点が、最初につまずきやすい箇所です。
-- 3件目から3件(=2件飛ばして3件)
SELECT id, name, score
FROM scores
ORDER BY score DESC, id
LIMIT 3 OFFSET 2;
MySQL・PostgreSQL・SQLiteのどれでも、次の3行が返りました(表示はPostgreSQLのもの)。
id | name | score
----+-------+-------
2 | Baba | 85
3 | Chiba | 85
4 | Doi | 70
(3 rows)
ページ番号から OFFSET を計算する式
一覧画面のページ送りでは、ページ番号と1ページの件数から次の式でOFFSETを求めます。
| 求めたい値 | 式 | 例(1ページ20件の3ページ目) |
|---|---|---|
| LIMIT | 1ページの件数 | 20 |
| OFFSET | (ページ番号 − 1) × 1ページの件数 | (3 − 1) × 20 = 40 |
| 取得される範囲 | OFFSET + 1 件目 〜 OFFSET + LIMIT 件目 | 41件目〜60件目 |
LIMIT m, n という省略形(MySQL・SQLite)
MySQLとSQLiteには、カンマ区切りの省略形 LIMIT m, n があります。カンマ形式は「飛ばす件数, 取り出す件数」の順で、LIMIT n OFFSET m とは数字の並びが逆になります。
-- MySQL・SQLite:2件飛ばして3件(LIMIT 3 OFFSET 2 と同じ)
SELECT id, name, score FROM scores ORDER BY score DESC, id LIMIT 2, 3;
MySQLとSQLiteでは LIMIT 3 OFFSET 2 と同じ3行(id 2、3、4)が返りました。PostgreSQLで同じSQLを流すと、次のエラーとヒントが表示されます。
ERROR: LIMIT #,# syntax is not supported
LINE 1: ...LECT id,name,score FROM scores ORDER BY score DESC LIMIT 2,3
^
HINT: Use separate LIMIT and OFFSET clauses.
複数のDBで同じSQLを使う可能性があるなら、数字の意味が読み取りやすく、3つのDBで共通に動く LIMIT n OFFSET m に統一しておくと、読み違いも移植の手間も減らせます。
標準SQLの書き方(FETCH FIRST と OFFSET ... FETCH)
LIMITは多くのDBが独自に採用してきた書き方で、標準SQLで定められているのは OFFSET m ROWS FETCH FIRST n ROWS ONLY という形です。PostgreSQLはLIMITと標準SQLの書き方の両方に対応しています。
-- PostgreSQL:上位2件
SELECT id, name, score FROM scores
ORDER BY score DESC
FETCH FIRST 2 ROWS ONLY;
-- PostgreSQL:2件飛ばして3件
SELECT id, name, score FROM scores
ORDER BY score DESC, id
OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY;
PostgreSQL 16.15 の実行結果(2つ目のSQL)です。LIMIT 3 OFFSET 2 と同じ結果になります。
id | name | score
----+-------+-------
2 | Baba | 85
3 | Chiba | 85
4 | Doi | 70
(3 rows)
FIRST と NEXT、ROW と ROWS はどちらを書いても同じ意味です。件数を省いた FETCH FIRST ROW ONLY は1件だけを返しました。
同じ FETCH FIRST 3 ROWS ONLY を MySQL 8.0.46 と SQLite 3.45.1 で流すと、どちらも構文エラーになります。
-- MySQL 8.0.46
ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FETCH FIRST 3 ROWS ONLY' at line 1
-- SQLite 3.45.1
Error: in prepare, near "FETCH": syntax error
今回の確認では、FETCH FIRSTが動いたのはPostgreSQLだけで、MySQL 8.0とSQLiteではLIMITを使う必要がありました。
SQL Serverの書き方(TOP と OFFSET ... FETCH)※未実行
ここからの2節は手元で実行しておらず、公式ドキュメントを開いて確認した構文です。
SQL ServerにはLIMITがなく、上位n件は SELECT の直後に TOP (n) を書きます。
-- SQL Server:上位3件(未実行)
SELECT TOP (3) id, name, score
FROM scores
ORDER BY score DESC, id;
-- SQL Server:2件飛ばして3件(未実行。SQL Server 2012以降)
SELECT id, name, score
FROM scores
ORDER BY score DESC, id
OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY;
Microsoft Learnの ORDER BY句 と TOP のページで確認できた要点は次のとおりです。
| 項目 | 内容(Microsoft Learnで確認) |
|---|---|
| OFFSET ... FETCH の対応バージョン | SQL Server 2012(11.x)以降 |
| ORDER BY の要否 | OFFSET・FETCH は ORDER BY句と組み合わせて使う |
| FETCHの書き方 | FETCH { FIRST | NEXT } n { ROW | ROWS } ONLY(OFFSETを書いたうえで付ける) |
| TOP との併用 | 同じクエリの中で TOP と OFFSET・FETCH は組み合わせられない |
| TOP で ORDER BY を省いた場合 | 最初のn行が「未定義の順序」で返る |
| TOP の追加指定 | TOP (n) PERCENT(割合)、TOP (n) WITH TIES(同順位を含める。ORDER BY が必要) |
SQL Serverの OFFSET ... FETCH は、OFFSETを省略して FETCH だけを書く形が構文にない点に注意します。先頭から取りたいときは OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY と書くか、TOPを使います。
Oracleの書き方(FETCH FIRST と ROWNUM)※未実行
Oracle Databaseでは、標準SQLの FETCH FIRST が使えます(Oracle Database 19c SQL言語リファレンスの SELECT のページで row_limiting_clause として確認。一般には12c以降で使えると知られています)。
-- Oracle:上位3件(未実行)
SELECT id, name, score
FROM scores
ORDER BY score DESC, id
FETCH FIRST 3 ROWS ONLY;
-- Oracle:2件飛ばして3件(未実行)
SELECT id, name, score
FROM scores
ORDER BY score DESC, id
OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY;
同じリファレンスによると、Oracleの構文は OFFSET と FETCH をそれぞれ単独でも書け、件数の代わりに PERCENT(割合)も指定でき、末尾は ONLY か WITH TIES を選びます。
ROWNUM を使う書き方
FETCH FIRST より前から使われてきたのが、ROWNUM という疑似列です。ROWNUMは「行が取り出された順に1、2、3…と振られる番号」で、リファレンスには次の2つの性質が書かれています。
| ROWNUMの性質(リファレンスで確認) | 書き方への影響 |
|---|---|
| 同じクエリでROWNUMのあとにORDER BYがあると、行はORDER BYで並べ替えられる(番号を振ったあとに並べ替わる) | 並べ替えはサブクエリの中で済ませ、外側でROWNUMを絞る |
正の整数より大きいROWNUMを調べる条件(ROWNUM > 1 など)は常に偽になる |
「m件目から」を取るには、ROWNUMに別名を付けてさらに外側で絞る |
-- Oracle:上位3件(未実行)。並べ替えをサブクエリに入れる
SELECT *
FROM (SELECT id, name, score FROM scores ORDER BY score DESC, id)
WHERE ROWNUM <= 3;
-- Oracle:3件目から5件目(未実行)。ROWNUM に別名 rn を付けて外側で絞る
SELECT id, name, score
FROM (
SELECT t.*, ROWNUM AS rn
FROM (SELECT id, name, score FROM scores ORDER BY score DESC, id) t
WHERE ROWNUM <= 5
)
WHERE rn >= 3;
ROWNUM > 1 が常に偽になるのは、「最初に取り出した行に1が振られて条件に合わず、次の行がまた1番目として扱われる」ことが繰り返されるためだとリファレンスで説明されています。新しく書くSQLでは、読みやすい FETCH FIRST か、次に紹介する ROW_NUMBER() を選ぶと意図が伝わりやすくなります。
なお、PostgreSQLで TOP や ROWNUM を書くと次のエラーになりました。DBを移すときに目にするメッセージです。
-- PostgreSQL 16.15: SELECT TOP 3 * FROM scores
ERROR: syntax error at or near "3"
-- PostgreSQL 16.15: SELECT * FROM scores WHERE ROWNUM<=3
ERROR: column "rownum" does not exist
5つのDBで共通に書ける ROW_NUMBER()
DBごとの差をなくしたいときは、ウィンドウ関数の ROW_NUMBER() で行番号を振り、外側のWHEREで範囲を絞ります。 ウィンドウ関数とは、行をまとめずに「並び順の中での位置」などを各行に付けられる関数です。
-- 3件目から5件目
SELECT id, name, score, rn
FROM (
SELECT id, name, score,
ROW_NUMBER() OVER (ORDER BY score DESC, id) AS rn
FROM scores
) t
WHERE rn BETWEEN 3 AND 5
ORDER BY rn;
SQLite 3.45.1 の実行結果です。MySQL 8.0.46 でも同じ3行でした。
id name score rn
-- ----- ----- --
2 Baba 85 3
3 Chiba 85 4
4 Doi 70 5
rn BETWEEN 3 AND 5 は「3件目から5件目」という言葉どおりに書けるので、OFFSETの「1つずれ」を考えずに済みます。ROW_NUMBER()はMySQL 8.0以降・PostgreSQL・SQLite 3.25以降・SQL Server・Oracleで使える関数として知られています(今回動かしたのはMySQL・PostgreSQL・SQLiteの3つです)。
グループごとに上位n件を取る
LIMITはクエリ全体に対して1回しか効かないので、「カテゴリごとに新しい順で2件ずつ」のような取り方は PARTITION BY を付けた ROW_NUMBER() で書きます。次は、あとの節で作る100万行の items 表(cat がカテゴリ番号)で試した例です。
SELECT *
FROM (
SELECT id, cat, title,
ROW_NUMBER() OVER (PARTITION BY cat ORDER BY id DESC) AS rn
FROM items
WHERE cat IN (1, 2)
) t
WHERE rn <= 2;
PostgreSQL 16.15 の実行結果です。カテゴリ1と2から、それぞれidの大きい順に2件ずつ取れています。
id | cat | title | rn
--------+-----+-------------+----
999679 | 1 | item-999679 | 1
998679 | 1 | item-998679 | 2
999358 | 2 | item-999358 | 1
998358 | 2 | item-998358 | 2
(4 rows)
同じサンプル表での実行結果まとめ
ここまでの書き方を、同じ scores 表・同じ並び(score DESC, id)で流した結果を1枚にまとめます。「返ったid」は左から返った順です。
| 書き方 | MySQL 8.0.46 | PostgreSQL 16.15 | SQLite 3.45.1 |
|---|---|---|---|
LIMIT 3 |
5, 1, 2 | 5, 1, 2 | 5, 1, 2 |
LIMIT 3 OFFSET 2 |
2, 3, 4 | 2, 3, 4 | 2, 3, 4 |
LIMIT 2, 3 |
2, 3, 4 | エラー(LIMIT #,# syntax is not supported) | 2, 3, 4 |
OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY |
エラー(1064) | 2, 3, 4 | エラー(near "FETCH") |
ROW_NUMBER() で rn BETWEEN 3 AND 5 |
2, 3, 4 | 2, 3, 4 | 2, 3, 4 |
TOP (3)(SQL Server) |
未実行 | エラー(syntax error) | 未実行 |
ROWNUM <= 3(Oracle) |
未実行 | エラー(column "rownum" does not exist) | 未実行 |
ORDER BYなしのLIMITは結果が変わる
ORDER BYを書かないLIMITは「どの行が返るか」が決まっていません。 表は「行の集まり」であって順番を持たないため、DBはそのとき読み出しやすい順に行を返します。小さな表では毎回同じに見えますが、更新やインデックスの追加で入れ替わります。実際に確かめた2つの例を紹介します。
例1:PostgreSQLで1行UPDATEしただけで変わる
SELECT id, name FROM scores LIMIT 3; -- 1回目
UPDATE scores SET name = 'Aoki' WHERE id = 1; -- 値は変えていない更新
SELECT id, name FROM scores LIMIT 3; -- 2回目
PostgreSQL 16.15 の実行結果です。
-- 1回目
id | name
----+-------
1 | Aoki
2 | Baba
3 | Chiba
(3 rows)
-- 2回目(UPDATEのあと)
id | name
----+-------
2 | Baba
3 | Chiba
4 | Doi
(3 rows)
PostgreSQLのUPDATEは新しい行を別の場所に書き込む仕組みのため、id=1の行が表の後ろへ移り、先頭から読んだ3行の顔ぶれが変わりました。
例2:MySQLでインデックスを追加すると変わる
SELECT id, score FROM scores LIMIT 3; -- 追加前
CREATE INDEX idx_score ON scores(score);
SELECT id, score FROM scores LIMIT 3; -- 追加後
MySQL 8.0.46 の実行結果です。
-- 追加前
+----+-------+
| id | score |
+----+-------+
| 1 | 90 |
| 2 | 85 |
| 3 | 85 |
+----+-------+
-- 追加後
+----+-------+
| id | score |
+----+-------+
| 7 | 60 |
| 4 | 70 |
| 6 | 70 |
+----+-------+
インデックスを足したあとは、id と score だけで済むこのSQLを新しいインデックスから読むようになり、点数の低い順に3行が返りました。同じ状態で列に name を加えた SELECT id, name, score FROM scores LIMIT 3 は、id 1、2、3 を返しています。SELECTする列を変えただけでも返る行が変わるということです。SQLiteでは同じ操作をしても id 1、2、3 のままでしたが、これも「たまたまそう読んだ」結果で、保証された順番ではありません。
| 直す前 | 直した後 |
|---|---|
SELECT * FROM scores LIMIT 3; |
SELECT * FROM scores ORDER BY id LIMIT 3; |
| 返る行は読み出し順しだい | 返る行が毎回同じに決まる |
データの中身をざっと眺めたいだけならORDER BYなしのLIMITで十分です。画面表示・集計・バッチ処理のように結果を使う場面では、必ずORDER BYを添えると覚えておくと安心です。
同順位の扱い(ORDER BYの決め手とWITH TIES)
並び順が一意でないと、ページをまたいで行が重複・欠落する
ORDER BYを書いていても、並べる列に同じ値があると、同点の中での順番は決まりません。score だけで並べ、3件目と4件目を1件ずつ取ってみます(MySQL 8.0.46、前の節で idx_score を追加した状態)。
SELECT id, name, score FROM scores ORDER BY score DESC LIMIT 1 OFFSET 2; -- (A) 3件目
SELECT id, name, score FROM scores ORDER BY score DESC LIMIT 1 OFFSET 3; -- (A) 4件目
SELECT id, score FROM scores ORDER BY score DESC LIMIT 1 OFFSET 2; -- (B) 3件目
SELECT id, score FROM scores ORDER BY score DESC LIMIT 1 OFFSET 3; -- (B) 4件目
| SQL | 3件目(OFFSET 2) | 4件目(OFFSET 3) |
|---|---|---|
(A) id, name, score を取得 |
id=2(Baba、85) | id=3(Chiba、85) |
(B) id, score を取得 |
id=3(85) | id=2(85) |
85点の2人の順番が、取得する列の違いだけで入れ替わりました。もし3件目を(A)で、4件目を(B)で取ると、id=2が2回出て、id=3が一度も出ないことになります。ページ送りで「同じ行が次のページにも出る」「ある行がどのページにも出ない」という現象は、この仕組みで起こります。
対策は、ORDER BYの最後に主キーなど重複しない列を足して、並び順を一意にすることです。
-- 直した後:同点のときは id 順、と決める
SELECT id, name, score FROM scores ORDER BY score DESC, id LIMIT 1 OFFSET 2;
同順位を全部含める WITH TIES(PostgreSQL 13以降)
「上位2位まで」と言われたとき、2位が同点で複数いたら全員を含めたい場合があります。FETCH FIRST n ROWS WITH TIES は、n件目と同じ順位の行をまとめて返す指定です。違いが見えるよう、3位が同点(85点が2人)になる上位3件で比べます。
-- ONLY:きっかり3件
SELECT id, name, score FROM scores ORDER BY score DESC FETCH FIRST 3 ROWS ONLY;
-- WITH TIES:3件目と同点の行も含める
SELECT id, name, score FROM scores ORDER BY score DESC FETCH FIRST 3 ROWS WITH TIES;
PostgreSQL 16.15 の実行結果です。ONLY はきっかり3行で切られ(85点の2人のどちらが入るかは決まっていません)、WITH TIES は3件目のBaba(85点)と同点のChiba(85点)も含めた4行になりました。
-- FETCH FIRST 3 ROWS WITH TIES
id | name | score
----+-------+-------
5 | Endo | 95
1 | Aoki | 90
2 | Baba | 85
3 | Chiba | 85
(4 rows)
WITH TIES は「同順位かどうか」をORDER BYの列で判断するので、ORDER BYを省くとPostgreSQLでは次のエラーになりました。
ERROR: WITH TIES cannot be specified without ORDER BY clause
SQL Serverの TOP (n) WITH TIES も ORDER BY句が必要で(Microsoft Learnで確認)、Oracleの FETCH FIRST n ROWS WITH TIES も同様です(19cリファレンスで確認)。どちらも未実行です。WITH TIESを使うときは、ORDER BYに主キーを足さない点にも注意します。ORDER BY score DESC, id のように一意にすると同順位がなくなり、PostgreSQLで試したところ WITH TIES を付けても3行(Endo、Aoki、Baba)だけが返りました。
MySQL・SQLiteでは RANK() で同順位を含める
MySQL 8.0とSQLiteにはWITH TIESがないので、順位を返す RANK() で絞ります。RANK()は同点に同じ順位を付け、次の順位を飛ばす関数です。
SELECT id, name, score, rk
FROM (
SELECT id, name, score, RANK() OVER (ORDER BY score DESC) AS rk
FROM scores
) t
WHERE rk <= 3
ORDER BY rk, id;
MySQL 8.0.46 の実行結果です。85点の2人がどちらも3位(rk=3)になり、4行が返りました。
+----+-------+-------+----+
| id | name | score | rk |
+----+-------+-------+----+
| 5 | Endo | 95 | 1 |
| 1 | Aoki | 90 | 2 |
| 2 | Baba | 85 | 3 |
| 3 | Chiba | 85 | 3 |
+----+-------+-------+----+
ROW_NUMBER()とRANK()を並べて取得した結果(MySQL 8.0.46、3〜5件目の部分)を見ると、違いがはっきりします。
+----+-------+-------+----+----+
| id | name | score | rn | rk |
+----+-------+-------+----+----+
| 2 | Baba | 85 | 3 | 3 |
| 3 | Chiba | 85 | 4 | 3 |
| 4 | Doi | 70 | 5 | 5 |
+----+-------+-------+----+----+
BabaとChibaは rn(ROW_NUMBER)では3と4に分かれますが、rk(RANK)ではどちらも3です。これが、rk <= 3 で同点の2人をどちらも取得できた理由です。
| やりたいこと | 関数 | 85点が2人のときの番号 |
|---|---|---|
| きっかりn件 | ROW_NUMBER() |
3, 4(別々の番号) |
| 同順位を含めて上位n位 | RANK() |
3, 3(次は5) |
| 「上位n種類の点数」に入る行すべて | DENSE_RANK() |
3, 3(次は4) |
なお、「上位2種類の点数に入る行」をサブクエリのLIMITで書く方法は、SQLiteでは動き、MySQL 8.0.46ではエラーになりました。MySQLでは上の RANK()・DENSE_RANK() の形を使います。
SELECT * FROM scores
WHERE score IN (SELECT DISTINCT score FROM scores ORDER BY score DESC LIMIT 2);
-- MySQL 8.0.46
ERROR 1235 (42000) at line 1: This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'
LIMIT・OFFSETの境界値の動き
端の値を入れたときの動きを、3つのDBで確かめた結果です。
| 書き方 | MySQL 8.0.46 | PostgreSQL 16.15 | SQLite 3.45.1 |
|---|---|---|---|
LIMIT 0 |
0行 | 0行 | 0行 |
LIMIT 100 OFFSET 5(残りは2行) |
あるだけの2行(id 6, 7) | 2行(id 6, 7) | 2行(id 6, 7) |
LIMIT 3 OFFSET -1 |
エラー 1064(near '-1') | ERROR: OFFSET must not be negative |
先頭から3行(負のOFFSETは0扱い) |
LIMITなしで OFFSET 5 だけ |
エラー 1064(near 'OFFSET 5') | 動く(id 6, 7) | エラー(near "OFFSET": syntax error) |
LIMIT ALL OFFSET 5 |
エラー 1064 | 動く(id 6, 7) | エラー(near "ALL": syntax error) |
LIMIT NULL |
エラー 1064 | 全7行(制限なし) | エラー(datatype mismatch) |
LIMIT -1 OFFSET 5 |
エラー 1064 | ERROR: LIMIT must not be negative |
残り全部(id 6, 7) |
LIMIT 5, 18446744073709551615 |
残り全部(id 6, 7) | エラー(カンマ形式なし) | エラー(datatype mismatch) |
LIMIT 1+1 OFFSET 2*2(式) |
エラー 1064(LIMIT 1+1 で near '+1') |
動く(id 5, 6) | 動く(id 5, 6) |
プレースホルダ LIMIT ? OFFSET ? |
動く(PREPAREで 2 と 4 を渡し id 5, 6) | 未実行 | 未実行 |
「m件飛ばして残り全部」の書き方は3つのDBで異なります。 PostgreSQLは OFFSET m だけ、SQLiteは LIMIT -1 OFFSET m、MySQLは件数に十分大きな数(符号なし64ビット整数の最大値 18446744073709551615)を指定する形で動きました。
残りの行数より大きいLIMITを指定しても、あるだけの行が返るだけでエラーにはなりません。 そのため最後のページを特別扱いする必要はありません。MySQLはLIMITに式を直接書けないので、計算はアプリケーション側で済ませて数値を渡すか、プリペアドステートメントのプレースホルダを使います。
-- MySQL:プレースホルダで件数と開始位置を渡す
PREPARE s FROM 'SELECT id FROM scores ORDER BY id LIMIT ? OFFSET ?';
SET @a = 2, @b = 4;
EXECUTE s USING @a, @b; -- id 5, 6 が返る
深いOFFSETが遅い理由
OFFSETは「m件を読んでから捨てる」動きをするため、mが大きいほど読む行が増えます。 「90万件飛ばして20件」は、20件だけ読むのではなく、90万20件を読んで90万件を捨てています。
100万行の表を作って確かめます。
-- PostgreSQL
CREATE TABLE items AS
SELECT g AS id, 'item-' || g AS title, ((g::bigint * 7919) % 1000)::int AS cat
FROM generate_series(1, 1000000) g;
ALTER TABLE items ADD PRIMARY KEY (id);
ANALYZE items;
-- MySQL
CREATE TABLE items (id INT PRIMARY KEY, title VARCHAR(30), cat INT);
SET SESSION cte_max_recursion_depth = 1000000;
INSERT INTO items
WITH RECURSIVE g(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM g WHERE n < 1000000)
SELECT n, CONCAT('item-', n), (n * 7919) % 1000 FROM g;
先頭のページと、90万件飛ばしたページを比べます。
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, title FROM items ORDER BY id LIMIT 20 OFFSET 900000;
PostgreSQL 16.15 の実行結果(要点)です。
Limit (actual time=197.566..197.572 rows=20 loops=1)
Buffers: shared hit=1513 read=6682 dirtied=4351
-> Index Scan using items_pkey on items (actual time=0.014..160.664 rows=900020 loops=1)
Execution Time: 197.603 ms
rows=900020 が「実際に読んだ行数」です。返すのは20行でも、インデックスを90万20行ぶんたどっています。OFFSET 0 のときは rows=20、Execution Time: 0.070 ms でした。MySQL 8.0.46 でも同じ傾向です。
-> Limit/Offset: 20/900000 row(s) (cost=81789 rows=20) (actual time=188..188 rows=20 loops=1)
-> Index scan on items using PRIMARY (cost=81789 rows=900020) (actual time=0.207..152 rows=900020 loops=1)
キーセット方式への書き換え
キーセット方式とは、「何件飛ばすか」ではなく「前のページの最後の行の次から」とWHEREで指定する取り方です。 前のページの最後の id が 900000 だったなら、次のように書きます。
-- 書き換え前(OFFSET方式)
SELECT id, title FROM items ORDER BY id LIMIT 20 OFFSET 900000;
-- 書き換え後(キーセット方式)
SELECT id, title FROM items WHERE id > 900000 ORDER BY id LIMIT 20;
PostgreSQL 16.15 の実行結果(要点)です。
Limit (actual time=0.051..0.056 rows=20 loops=1)
-> Index Scan using items_pkey on items (actual time=0.051..0.053 rows=20 loops=1)
Index Cond: (id > 900000)
Execution Time: 0.077 ms
インデックスで id > 900000 の位置へ直接移動し、そこから20行だけ読んでいます。3つのDBでの計測結果(各1回の計測)をまとめます。
| DB | OFFSET 900000 | キーセット(id > 900000) |
読んだ行数(OFFSET → キーセット) |
|---|---|---|---|
| PostgreSQL 16.15 | 197.603 ms | 0.077 ms | 900,020行 → 20行 |
| MySQL 8.0.46 | 188 ms | 0.129 ms | 900,020行 → 20行 |
| SQLite 3.45.1 | 0.018 秒 | 0.000 秒(計測下限未満) | ―(行数表示なし) |
数値は今回のコンテナでの1回の計測値で、環境によって変わります。大切なのは絶対値ではなく、OFFSET方式は位置が深くなるほど読む行が増え、キーセット方式は位置に関係なく取り出す件数ぶんしか読まないという違いです。
並び順が複数列のときのキーセット
「点数の高い順、同点はidの大きい順」のように複数列で並べる場合は、前のページの最後の行の値を組にして比べます。最後の行が(score=85, id=2)だったとすると、次のように書けます。
-- 並びが score DESC, id DESC の場合:行値の比較で書ける
SELECT id, name, score
FROM scores
WHERE (score, id) < (85, 2)
ORDER BY score DESC, id DESC
LIMIT 3;
(score, id) < (85, 2) は「scoreが85より小さい、またはscoreが85でidが2より小さい」という意味の行値比較です。MySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1 のすべてで、次の同じ3行が返りました。
id | name | score
----+-------+-------
6 | Fujii | 70
4 | Doi | 70
7 | Goto | 60
(3 rows)
昇順と降順が混ざる並び(score DESC, id ASC)では行値比較が使えないので、ORで展開します。
-- 並びが score DESC, id ASC の場合:最後の行が (85, 2) のあと
SELECT id, name, score
FROM scores
WHERE score < 85 OR (score = 85 AND id > 2)
ORDER BY score DESC, id
LIMIT 3;
PostgreSQL 16.15 では、id 3(Chiba)、4(Doi)、6(Fujii)が返り、LIMIT 3 OFFSET 3 と同じ続きが取れました。
OFFSET方式とキーセット方式の選び方
| 観点 | OFFSET方式 | キーセット方式 |
|---|---|---|
| 書きやすさ | ページ番号から計算するだけ | 前ページ最後の行の値を持ち回る必要がある |
| 任意のページへ直接移動 | できる(5ページ目へジャンプなど) | 「次へ」「前へ」が基本 |
| 深い位置での速さ | 位置に比例して読む行が増える | 位置に関係なく一定 |
| 途中で行が追加・削除されたとき | 行がずれて重複・欠落が起こりうる | 続きの位置が値で決まるのでずれにくい |
| 向いている場面 | 件数が少ない一覧、ページ番号を表示する画面 | 「もっと見る」形式、無限スクロール、大量データの順次処理 |
| 必要な準備 | 一意になるORDER BY | 一意になるORDER BYと、その列のインデックス |
管理画面のように数百件までの一覧ならOFFSET方式で十分です。件数が増え続ける表や、全件を少しずつ処理するバッチではキーセット方式を選ぶと、後ろのページでも速さが変わりません。
UPDATE・DELETEで件数を絞る
MySQLは、UPDATEとDELETEにも ORDER BY ... LIMIT を書けます。大量の行を少しずつ消したいときに便利です。
-- MySQL:点数の低い順に1件だけ削除
DELETE FROM scores ORDER BY score LIMIT 1;
-- MySQL:id順に先頭2件だけ更新
UPDATE scores SET score = score + 1 ORDER BY id LIMIT 2;
MySQL 8.0.46 では、直後の SELECT ROW_COUNT() がそれぞれ 1 と 2 を返し、指定した件数だけ処理されたことを確認できました。PostgreSQLで同じDELETEを流すと構文エラーになります。
ERROR: syntax error at or near "ORDER"
LINE 1: DELETE FROM scores ORDER BY score LIMIT 1
^
PostgreSQLやSQLiteでは、サブクエリで対象の主キーを絞ってから処理します。
-- PostgreSQL・SQLite:対象の主キーをサブクエリのLIMITで絞る
DELETE FROM scores
WHERE id IN (SELECT id FROM scores ORDER BY score LIMIT 1)
RETURNING id, score;
PostgreSQL 16.15 と SQLite 3.45.1 のどちらでも、いちばん点数の低い1行(id=7、score=60)だけが削除されました。RETURNING は削除した行を返す指定で、どの行が消えたかをその場で確かめられます。
id | score
----+-------
7 | 60
(1 row)
DELETE 1
UNIONや集計と組み合わせるときの注意
UNIONの各SELECTにLIMITを付けるときは括弧で囲む
括弧なしで末尾にLIMITを書くと、UNIONした結果全体に対するLIMITになります。それぞれのSELECTに効かせたいときは括弧で囲みます。
(SELECT id FROM scores ORDER BY id LIMIT 2)
UNION ALL
(SELECT id FROM scores ORDER BY id DESC LIMIT 2);
PostgreSQL 16.15 では、先頭の2件(1、2)と末尾の2件(7、6)の4行が返りました。
集計にLIMITを付けても、集計対象は減らない
SELECT COUNT(*) FROM scores LIMIT 3 は、3行だけ数えるSQLではありません。LIMITは「結果の行数」を制限するもので、COUNT(*)の結果は1行なのでそのまま返ります。PostgreSQL 16.15 では 7 が返りました。「上位3件だけを集計したい」ときは、サブクエリでLIMITしてから集計します。
SELECT MAX(id), COUNT(*)
FROM (SELECT id FROM scores ORDER BY id LIMIT 3) t; -- 3, 3 が返る
よくあるつまずきと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
| 実行するたびに返る行が違う | ORDER BYがない | ORDER BYを付ける |
| ページをまたいで同じ行が出る・出ない行がある | ORDER BYの列に同じ値がある | ORDER BYの最後に主キーを足す |
| 「3件目から」のつもりが4件目から返る | OFFSETに3を指定している | OFFSETは「件目 − 1」にする |
LIMIT 2, 3 の結果が想定と違う |
カンマ形式は「飛ばす件数, 取る件数」の順 | LIMIT 3 OFFSET 2 と書く |
PostgreSQLで LIMIT #,# syntax is not supported |
カンマ形式はPostgreSQLにない | LIMIT n OFFSET m に書き換える |
MySQL・SQLiteで FETCH FIRST が構文エラー |
今回のバージョンでは対応していない | LIMIT n に書き換える |
MySQLで LIMIT 1+1 が1064エラー |
LIMITに式を書けない | 数値を計算してから渡す、またはプレースホルダを使う |
| 後ろのページほど遅い | OFFSETぶんの行を読んで捨てている | キーセット方式に書き換える |
| 同点の行が上位n件から漏れる | LIMITはきっかりn件で切る | WITH TIES または RANK() を使う |
Oracleで ROWNUM > 1 が0行(リファレンスの記述) |
ROWNUMは取り出し時に1から振られる | サブクエリで別名を付けて外側で絞る |
よくある質問
Q. LIMITとOFFSETはどちらを先に書きますか?
A. LIMIT n OFFSET m の順が一般的で、今回MySQL・PostgreSQL・SQLiteの3つすべてで動きました。標準SQLの書き方では逆に OFFSET m ROWS FETCH NEXT n ROWS ONLY と、OFFSETを先に書きます。
Q. ORDER BYを書かなくても毎回同じ結果が返ってきます。付けなくてよいですか? A. 付けることをおすすめします。本文の例のとおり、PostgreSQLでは1行のUPDATE、MySQLではインデックスの追加だけで返る行が変わりました。今同じ結果が返っていても、順番は保証されていません。
Q. 全体の件数(総ページ数)はLIMIT付きのSQLで取れますか?
A. LIMITを付けたSELECTが返すのは切り出した行だけなので、総件数は SELECT COUNT(*) FROM 表 WHERE 同じ条件 を別に実行して求めるのが基本です。件数が多い表ではCOUNT自体に時間がかかるため、「次のページがあるか」だけ知りたいときは、LIMITを1件多く(20件表示なら21件)取得し、21件目があるかで判断する方法もあります。
Q. SQL ServerやOracleでLIMITと書くとどうなりますか?
A. どちらにもLIMIT句はないため、SQL Serverでは TOP か OFFSET ... FETCH、Oracleでは FETCH FIRST か ROWNUM に書き換えます(この記事では両DBとも未実行で、構文は公式ドキュメントで確認しました)。DBをまたいで使うSQLは ROW_NUMBER() で書いておくと書き換えが要りません。
まとめ
- 上位n件は
ORDER BY ... LIMIT n、m件飛ばしてn件はLIMIT n OFFSET m。MySQL・PostgreSQL・SQLiteで共通に動く - SQL Serverは
TOP (n)とOFFSET m ROWS FETCH NEXT n ROWS ONLY、OracleはFETCH FIRST n ROWS ONLYとROWNUM。5つのDBで揃えるならROW_NUMBER() - OFFSETは「読み飛ばす件数」。3件目からなら
OFFSET 2、ページ番号からは (ページ番号 − 1) × 件数 - ORDER BYなしのLIMITは返る行が決まらない。UPDATEやインデックス追加で実際に結果が変わった
- ORDER BYの最後に主キーを足して並びを一意にすると、ページをまたいだ重複・欠落を防げる
- 同順位を含めたいときは
WITH TIES(PostgreSQL・SQL Server・Oracle)かRANK() - 深いOFFSETは読み飛ばす行ぶん遅くなる。
WHERE id > 前回の最後のキーセット方式なら、90万件目でも20行しか読まない
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL LIMIT OFFSETの使い方|上位n件取得とDB別構文の早見表」
株式会社RJCでは、SQLの書き方やDBごとの違いを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。