SQLのORDER BYで複数列を並べ替えるには、ORDER BY 列1 ASC, 列2 DESC のようにカンマで区切り、列ごとに昇順(ASC)・降順(DESC)を指定します。 左に書いた列が最優先で、同じ値の行どうしだけが次の列で並べ替えられます。ASC は省略でき、DESC は付けた列だけに効きます。

この記事では、同じテーブル・同じSQLを MySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1 で実際に流した結果を載せています。特に「NULLは先頭に来るのか末尾に来るのか」はデータベースごとに逆になるため、5つのDBMSの一覧表と、NULLS FIRST/NULLS LAST が使えないデータベース向けの代替式をまとめました。任意の順番(CASE・FIELD)、1, 10, 2 と並ぶ文字列の数字、日本語の並び、LIMIT と組み合わせたときに行が重複する現象、インデックスで並べ替えを省く方法まで、コピペで試せる形で順に見ていきます。

MySQL 8.0 は2026年4月にサポートが終了しました。この記事の MySQL 向けSQLは 8.4/9.7 でも同じ手順で使えます(違いがある箇所は本文で明記します)。検証は手元の 8.0.46 で行いました。

ORDER BYの早見表

やりたいこと 書き方 補足
昇順(小さい順) ORDER BY price ASC は省略可
降順(大きい順) ORDER BY price DESC
複数列 ORDER BY category, price 左の列が優先
昇順と降順をまぜる ORDER BY category ASC, price DESC DESC は直前の列だけに効く
列番号で指定 ORDER BY 2 DESC, 1 SELECT 句の何番目か。列の追加で意味が変わる
別名で指定 SELECT price*stock AS total ... ORDER BY total 別名を式の中で使うと PostgreSQL はエラー
式で指定 ORDER BY price * stock DESC インデックスは使われにくい
NULLを末尾に(PostgreSQL・SQLite・Oracle) ORDER BY price NULLS LAST SQLite は 3.30.0 以降
NULLを末尾に(全DB共通) ORDER BY CASE WHEN price IS NULL THEN 1 ELSE 0 END, price MySQL は price IS NULL, price でも可
任意の順番 ORDER BY CASE status WHEN '販売中' THEN 1 ... END MySQL は FIELD() も可
文字列の数字を数値順に ORDER BY CAST(code AS SIGNED)(MySQL) PostgreSQL は CAST(code AS INT)
順番を必ず固定する ORDER BY priority, id 最後に一意な列を足す

NULL の既定の位置は次のとおりです(詳しい表は後述)。

DBMS 昇順(ASC)のNULL 降順(DESC)のNULL
MySQL・SQLite・SQL Server 先頭 末尾
PostgreSQL・Oracle 末尾 先頭

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

(記載例)出典:株式会社RJC「SQL ORDER BY 複数列の昇順・降順とNULLの順番まとめ」

検証に使うテーブル(3つのDBで共通)

以降のSQLは、次の items テーブルで試せます。MySQL・PostgreSQL・SQLite のどれにもそのまま貼れます。price に NULL を2行、code に文字列の数字を入れてあります。

DROP TABLE IF EXISTS items;
CREATE TABLE items (
  id INT PRIMARY KEY,
  name VARCHAR(20) NOT NULL,
  category VARCHAR(10) NOT NULL,
  price INT,
  stock INT NOT NULL,
  status VARCHAR(10) NOT NULL,
  code VARCHAR(10) NOT NULL
);
INSERT INTO items VALUES
(1,'ノート','文具',300,50,'販売中','1'),
(2,'ペン','文具',150,0,'準備中','10'),
(3,'マウス','周辺機器',1800,12,'販売中','2'),
(4,'キーボード','周辺機器',NULL,5,'終了','20'),
(5,'付箋','文具',300,30,'販売中','3'),
(6,'USBハブ','周辺機器',1800,0,'準備中','100'),
(7,'消しゴム','文具',NULL,80,'終了','A1');

実行環境は次のとおりです。

DBMS バージョン 既定の照合順序(検証環境)
MySQL 8.0.46 utf8mb4_0900_ai_ci
PostgreSQL 16.15 C.UTF-8
SQLite 3.45.1 BINARY

複数列で並べ替える(左の列が優先)

ORDER BY にカンマ区切りで列を並べると、左から順に比較されます。 1列目が同じ値の行だけ、2列目で順番が決まります。

SELECT id, name, category, price
FROM items
ORDER BY category, price;

MySQL 8.0.46 の実行結果です。

+----+-----------------+--------------+-------+
| id | name            | category     | price |
+----+-----------------+--------------+-------+
|  4 | キーボード      | 周辺機器     |  NULL |
|  3 | マウス          | 周辺機器     |  1800 |
|  6 | USBハブ         | 周辺機器     |  1800 |
|  7 | 消しゴム        | 文具         |  NULL |
|  2 | ペン            | 文具         |   150 |
|  1 | ノート          | 文具         |   300 |
|  5 | 付箋            | 文具         |   300 |
+----+-----------------+--------------+-------+

同じSQLを PostgreSQL 16.15 で流すと、NULL の位置だけが変わります。

 id |    name    | category | price 
----+------------+----------+-------
  3 | マウス     | 周辺機器 |  1800
  6 | USBハブ    | 周辺機器 |  1800
  4 | キーボード | 周辺機器 |      
  2 | ペン       | 文具     |   150
  1 | ノート     | 文具     |   300
  5 | 付箋       | 文具     |   300
  7 | 消しゴム   | 文具     |      

カテゴリの中で価格の小さい順、という並びは同じですが、NULL が MySQL では各カテゴリの先頭、PostgreSQL では末尾に来ています。 この違いは後の「NULLの順番」で詳しく扱います。

昇順と降順をまぜる

ASC/DESC は列ごとに付けます。 ORDER BY category, price DESC と書いたとき、DESC が効くのは price だけで、category は昇順のままです。

SELECT id, name, category, price, stock
FROM items
ORDER BY category ASC, price DESC, id ASC;

MySQL 8.0.46 の実行結果です。

+----+-----------------+--------------+-------+-------+
| id | name            | category     | price | stock |
+----+-----------------+--------------+-------+-------+
|  3 | マウス          | 周辺機器     |  1800 |    12 |
|  6 | USBハブ         | 周辺機器     |  1800 |     0 |
|  4 | キーボード      | 周辺機器     |  NULL |     5 |
|  1 | ノート          | 文具         |   300 |    50 |
|  5 | 付箋            | 文具         |   300 |    30 |
|  2 | ペン            | 文具         |   150 |     0 |
|  7 | 消しゴム        | 文具         |  NULL |    80 |
+----+-----------------+--------------+-------+-------+

よくある書き間違いと直し方を表にしました。

書いたSQL 実際の意味 両方とも降順にしたいとき
ORDER BY category, price DESC category 昇順、price 降順 ORDER BY category DESC, price DESC
ORDER BY category DESC, price category 降順、price 昇順 同上

列番号で並べ替える

ORDER BY 2 は「SELECT 句の2番目の列で並べる」という意味です。 手元でさっと確認するときに便利です。

SELECT name, price FROM items ORDER BY 2 DESC, 1;
+-----------------+-------+
| name            | price |
+-----------------+-------+
| USBハブ         |  1800 |
| マウス          |  1800 |
| ノート          |   300 |
| 付箋            |   300 |
| ペン            |   150 |
| キーボード      |  NULL |
| 消しゴム        |  NULL |
+-----------------+-------+

SELECT 句に列を足したり順番を入れ替えたりすると、同じ ORDER BY 2 が別の列を指すようになります。SQL Server の公式ドキュメントも、列番号より列名で書くことをすすめています。アプリケーションに残すSQLは列名か別名で書くと、あとから読んでも意味が変わりません。

別名・式で並べ替える

SELECT 句で付けた別名は ORDER BY で使えます。ORDER BY は SELECT 句より後に評価されるためです(評価の順番は既存記事「SQLの実行順序」で扱っています)。

SELECT name, price * stock AS total
FROM items
ORDER BY total DESC;
+-----------------+-------+
| name            | total |
+-----------------+-------+
| マウス          | 21600 |
| ノート          | 15000 |
| 付箋            |  9000 |
| ペン            |     0 |
| USBハブ         |     0 |
| キーボード      |  NULL |
| 消しゴム        |  NULL |
+-----------------+-------+

SELECT 句に出していない式でも並べ替えられます。

SELECT name, price, stock FROM items ORDER BY price * stock DESC, id;

別名を「式の一部」として使えるかどうかは、データベースで分かれます。 ORDER BY total + 1 DESC を3つのDBで流した結果です。

DBMS ORDER BY total + 1 DESC 結果
MySQL 8.0.46 実行できる total の降順で返る
SQLite 3.45.1 実行できる total の降順で返る
PostgreSQL 16.15 エラー ERROR: column "total" does not exist

PostgreSQL では、別名は ORDER BY に単独で書いたときだけ使えます。式に組み込みたいときは ORDER BY price * stock + 1 DESC と元の式を書くか、サブクエリで包みます。

NULLの順番はデータベースで逆になる

NULL を「いちばん小さい値」として扱うか「いちばん大きい値」として扱うかが、DBMS によって違います。 同じSQLでも結果が変わるので、最初に押さえておきたい点です。

SELECT id, price FROM items ORDER BY price, id;
順番 MySQL 8.0.46 PostgreSQL 16.15 SQLite 3.45.1
1行目 4(NULL) 2(150) 4(NULL)
2行目 7(NULL) 1(300) 7(NULL)
3行目 2(150) 5(300) 2(150)
4行目 1(300) 3(1800) 1(300)
5行目 5(300) 6(1800) 5(300)
6行目 3(1800) 4(NULL) 3(1800)
7行目 6(1800) 7(NULL) 6(1800)

降順(ORDER BY price DESC, id)では逆になり、MySQL・SQLite は NULL が末尾、PostgreSQL は NULL が先頭でした。

NULLの既定位置とNULLS FIRST/LASTの対応表(5DBMS)

DBMS NULLの扱い ASC のNULL DESC のNULL NULLS FIRST/LAST 確認方法
MySQL 8.0/8.4/9.7 最小値として扱う 先頭 末尾 使えない(構文エラー) 8.0.46 で実行。8.4/9.7 は公式リファレンスの構文で確認
PostgreSQL 16 最大値として扱う 末尾 先頭 使える 16.15 で実行
SQLite 3.45 最小値として扱う 先頭 末尾 使える(3.30.0 以降) 3.45.1 で実行
Oracle Database 最大値として扱う 末尾 先頭 使える 未実行(一般に知られた仕様)
SQL Server 最小値として扱う 先頭 末尾 使えない 公式ドキュメントで確認(未実行)

SQL Server の公式ドキュメントには「NULL values are treated as the lowest possible values.」とあり、ORDER BY の構文に NULLS FIRST/NULLS LAST は含まれていません。MySQL の公式リファレンスにも「ORDER BY ... ASC では NULL が先、DESC では後」と書かれています。

NULLS FIRST/NULLS LASTの書き方(PostgreSQL・SQLite・Oracle)

NULLS FIRST/NULLS LAST は、ASC/DESC の後ろに列ごとに書きます。

-- NULL を先頭にして昇順
SELECT id, price FROM items ORDER BY price NULLS FIRST, id;

-- NULL を末尾にして降順
SELECT id, price FROM items ORDER BY price DESC NULLS LAST, id;

PostgreSQL 16.15 での2つ目のSQLの実行結果です。

 id | price 
----+-------
  3 |  1800
  6 |  1800
  1 |   300
  5 |   300
  2 |   150
  4 |      
  7 |      

SQLite 3.45.1 でも ORDER BY price NULLS LAST, id と ORDER BY price DESC NULLS FIRST, id が期待どおりに動きました。

MySQL 8.0.46 で同じ書き方をすると、次のエラーになります。

ERROR 1064 (42000) at line 2: 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 'NULLS LAST' at line 1

NULLS FIRST/LASTが使えないDBでの代替式

「NULL かどうか」を0と1に変換した式を、ORDER BY の先頭に置きます。 この書き方は5つのDBMSすべてで使える標準的なSQLです。

-- NULL を末尾にして昇順(全DB共通)
SELECT id, price
FROM items
ORDER BY CASE WHEN price IS NULL THEN 1 ELSE 0 END, price, id;

MySQL・PostgreSQL・SQLite の3つで、次の同じ結果になりました。

+----+-------+
| id | price |
+----+-------+
|  2 |   150 |
|  1 |   300 |
|  5 |   300 |
|  3 |  1800 |
|  6 |  1800 |
|  4 |  NULL |
|  7 |  NULL |
+----+-------+

やりたいことごとの代替式です。

やりたい並び 全DB共通の書き方 MySQL の短い書き方
昇順・NULL末尾 ORDER BY CASE WHEN price IS NULL THEN 1 ELSE 0 END, price ORDER BY price IS NULL, price
降順・NULL先頭 ORDER BY CASE WHEN price IS NULL THEN 0 ELSE 1 END, price DESC ORDER BY price IS NULL DESC, price DESC
昇順・NULL先頭(PostgreSQL・Oracle 向け) ORDER BY CASE WHEN price IS NULL THEN 0 ELSE 1 END, price (MySQL は既定でこの並び)
降順・NULL末尾(PostgreSQL・Oracle 向け) ORDER BY CASE WHEN price IS NULL THEN 1 ELSE 0 END, price DESC (MySQL は既定でこの並び)

MySQL では price IS NULL が 0 か 1 を返すので、そのまま並べ替えに使えます。数値列に限り ORDER BY -price DESC と符号を反転させる書き方でも「昇順・NULL末尾」になることを 8.0.46 で確認しました。ただし意図が読み取りにくいので、IS NULL を使う書き方のほうが伝わりやすくなります。

COALESCE(price, 999999) のように大きな値へ置き換える方法もありますが、実際の値がその数を超えると順番が入れ替わります。NULL の位置だけを決めたいときは、IS NULL の判定を先頭に置く方法が安定します。

任意の順番で並べ替える(CASE・FIELD)

「販売中 → 準備中 → 終了」のように、文字の順でも数値の順でもない並びにしたいときは、値を順番の数字に変換する式を ORDER BY に書きます。

CASE式(全DB共通)

SELECT id, name, status
FROM items
ORDER BY CASE status
           WHEN '販売中' THEN 1
           WHEN '準備中' THEN 2
           WHEN '終了'   THEN 3
           ELSE 9
         END, id;

MySQL・PostgreSQL・SQLite で同じ結果になりました(MySQL 8.0.46 の出力)。

+----+-----------------+-----------+
| id | name            | status    |
+----+-----------------+-----------+
|  1 | ノート          | 販売中    |
|  3 | マウス          | 販売中    |
|  5 | 付箋            | 販売中    |
|  2 | ペン            | 準備中    |
|  6 | USBハブ         | 準備中    |
|  4 | キーボード      | 終了      |
|  7 | 消しゴム        | 終了      |
+----+-----------------+-----------+

ELSE 9 を書いておくと、想定していない値が末尾にまとまります。ELSE を省くと NULL になり、NULL の位置はDBMSごとに違うため、結果もDBMSごとに変わります。

FIELD関数(MySQL)

MySQL には、引数の何番目に一致したかを返す FIELD() 関数があります。

SELECT id, name, status
FROM items
ORDER BY FIELD(status, '販売中', '準備中', '終了'), id;

結果は CASE 式と同じでした。一覧にない値は 0 が返るため、先頭に来ます。 '準備中' を一覧から外して試した結果です。

SELECT id, status, FIELD(status, '販売中', '終了') f FROM items ORDER BY f, id;
+----+-----------+---+
| id | status    | f |
+----+-----------+---+
|  2 | 準備中    | 0 |
|  6 | 準備中    | 0 |
|  1 | 販売中    | 1 |
|  3 | 販売中    | 1 |
|  5 | 販売中    | 1 |
|  4 | 終了      | 2 |
|  7 | 終了      | 2 |
+----+-----------+---+

一覧にない値を末尾に回したいときは、ORDER BY FIELD(status, '販売中', '終了') = 0, FIELD(status, '販売中', '終了') と「0 かどうか」を先に並べます。

array_position(PostgreSQL)

PostgreSQL では array_position() が FIELD に近い役割を果たします。一覧にない値は NULL になり、PostgreSQL の昇順では末尾に並びます。

SELECT id, name, status
FROM items
ORDER BY array_position(ARRAY['販売中','準備中','終了'], status::text), id;

PostgreSQL 16.15 で、CASE 式と同じ並びになることを確認しました。

方法 使えるDB 一覧にない値 向いている場面
CASE 式 すべて ELSE で自由に決められる 複数のDBで同じSQLを使う
FIELD() MySQL 0(先頭) 短く書きたい
array_position() PostgreSQL NULL(昇順で末尾) 配列で順番を渡したい
順番用の列・マスタテーブル すべて — 順番を画面から変更したい、件数が多い

並び順が増えたり変わったりする場合は、sort_no のような列を持つマスタテーブルを JOIN して ORDER BY sort_no とすると、SQLを書き換えずに順番を変えられます。

文字列の数字が1, 10, 2の順になる理由と直し方

文字列型の列は、数値としてではなく「左から1文字ずつ」比較されます。 そのため '10' は '2' より前に来ます。

SELECT code FROM items ORDER BY code;

MySQL・PostgreSQL・SQLite の3つとも、次の同じ並びになりました。

+------+
| code |
+------+
| 1    |
| 10   |
| 100  |
| 2    |
| 20   |
| 3    |
| A1   |
+------+

数値の順にしたいときは、数値型に変換して並べます。ここで、数字以外の値('A1')が混ざっているときの挙動がDBMSで分かれました。

DBMS SQL 'A1' の扱い(実行結果)
MySQL 8.0.46 ORDER BY CAST(code AS SIGNED), code 0 として扱われ先頭。警告 Warning 1292: Truncated incorrect INTEGER value: 'A1'
SQLite 3.45.1 ORDER BY CAST(code AS INTEGER), code 0 として扱われ先頭。警告なし
PostgreSQL 16.15 ORDER BY CAST(code AS INT) ERROR: invalid input syntax for type integer: "A1"

MySQL 8.0.46 の結果です。

+------+
| code |
+------+
| A1   |
| 1    |
| 2    |
| 3    |
| 10   |
| 20   |
| 100  |
+------+

PostgreSQL で数字以外が混ざる列を並べるときは、数字だけの行を変換し、それ以外を末尾に回す書き方ができます。

SELECT code
FROM items
ORDER BY CASE WHEN code ~ '^[0-9]+$' THEN code::int END NULLS LAST, code;
 code 
------
 1
 2
 3
 10
 20
 100
 A1

変換を使わずに「桁数 → 文字」の順で並べる方法もあります。数字だけの行に絞れば、MySQL で次のSQLが数値順になりました。

SELECT code FROM items WHERE code REGEXP '^[0-9]+$' ORDER BY LENGTH(code), code;
直し方 長所 気をつける点
列を数値型にする 並べ替えも比較もそのまま正しく動く 先頭ゼロ(007)は保持できない
CAST して並べる テーブルを変えずに済む インデックスによる並べ替えは使われにくい
LENGTH(code), code 桁数の大きい数字でも使える 先頭ゼロや記号が混ざると順番が変わる
ゼロ埋めして保存(001、010) 文字列のまま正しく並ぶ 桁数を最初に決める必要がある

照合順序で日本語の並びが変わる

文字列の並び順は、照合順序(collation:文字の比較ルール)で決まります。 ひらがな・カタカナ・半角カナ・大文字小文字をどう並べるかは、照合順序ごとに違います。

13個の値(あい アイ アイ はは ハハ ばば パパ 山田 佐藤 a B A b)を ORDER BY w で並べた結果です。

MySQL 8.0.46(ORDER BY w COLLATE 照合順序名 で切り替え):

照合順序 並び順(実行結果)
utf8mb4_0900_ai_ci(既定) A a b B アイ アイ あい パパ ばば ハハ はは 佐藤 山田
utf8mb4_ja_0900_as_cs a A b B アイ アイ あい ハハ はは ばば パパ 佐藤 山田
utf8mb4_ja_0900_as_cs_ks a A b B あい アイ アイ はは ハハ ばば パパ 佐藤 山田
utf8mb4_bin A B a b あい はは ばば アイ ハハ パパ 佐藤 山田 アイ
utf8mb4_general_ci A a b B あい はは ばば アイ ハハ パパ 佐藤 山田 アイ

PostgreSQL 16.15(ORDER BY w COLLATE "照合順序名" で切り替え):

照合順序 並び順(実行結果)
C.UTF-8(検証環境の既定)/"C" A B a b あい はは ばば アイ ハハ パパ 佐藤 山田 アイ
"ja-x-icu" a A b B あい アイ アイ はは ハハ ばば パパ 佐藤 山田

この結果から読み取れることを3つ挙げます。

  • 既定の utf8mb4_0900_ai_ci は「あい・アイ・アイ」「はは・ハハ・ばば・パパ」を同じ値として扱います。 同じ値どうしの順番は決まらないため、上の表でも「パパ ばば ハハ はは」と五十音順に見えない並びになっています。
  • utf8mb4_bin や PostgreSQL の "C" は文字コード順です。ひらがな → カタカナ → 漢字 → 半角カナの順にまとまり、英字は大文字が先になります。
  • 五十音順に近い並びにしたいときは、MySQL なら utf8mb4_ja_0900_as_cs(または _ks 付き)、PostgreSQL なら "ja-x-icu" を指定します。 「はは → ハハ → ばば → パパ」と清音・濁音・半濁音の順になりました。

漢字(佐藤・山田)は読みではなく文字の順で並びます。名前を読みの順に並べたいときは、ふりがな用の列を用意して、その列で並べ替えます。

一時的に切り替えるSQLは次のとおりです。

-- MySQL
SELECT w FROM words ORDER BY w COLLATE utf8mb4_ja_0900_as_cs;

-- PostgreSQL
SELECT w FROM words ORDER BY w COLLATE "ja-x-icu";

SQLite 3.45.1 の既定(BINARY)では A B a b あい アイ、COLLATE NOCASE を付けると a A B b あい アイ になりました。NOCASE が大文字小文字を同一視するのは英字(ASCII)の範囲です。

照合順序の種類ごとの詳しい比較は、既存記事「MySQL照合順序(collation)の違い」にまとめています。

同じ値の行は順番が決まらない(LIMITで行が重複する現象)

ORDER BY に書いた列の値が同じ行どうしは、どの順で返るかが決まっていません。 ふだんは気づきにくいのですが、LIMIT/OFFSET でページを分けると「同じ行が2つのページに出る」「どのページにも出ない行がある」という形で表に出ます。

優先度(1〜3)だけを持つ30行のテーブルで再現します。

-- MySQL 8.0
CREATE TABLE tasks (id INT PRIMARY KEY, priority INT NOT NULL,
                    title VARCHAR(20), memo VARCHAR(200));
INSERT INTO tasks
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 30)
SELECT n, n % 3 + 1, CONCAT('task', n), REPEAT('x', 100) FROM s;

-- PostgreSQL 16
CREATE TABLE tasks (id INT PRIMARY KEY, priority INT NOT NULL, title VARCHAR(20));
INSERT INTO tasks SELECT n, n % 3 + 1, 'task' || n FROM generate_series(1, 30) n;

priority が 1 の行は id が 3, 6, 9, …, 30 の10行です。SELECT id FROM tasks ORDER BY priority LIMIT 5 OFFSET n で1ページ5行ずつ取った結果です。

SQLの末尾 MySQL 8.0.46 の id PostgreSQL 16.15 の id
ORDER BY priority LIMIT 5(1ページ目) 15 9 3 30 12 12 3 6 9 15
ORDER BY priority LIMIT 5 OFFSET 5(2ページ目) 9 27 30 3 6 3 12 9 18 30
ORDER BY priority, id LIMIT 5 OFFSET 5(一意な列を追加) 18 21 24 27 30 18 21 24 27 30

MySQL では id 3・9・30 が、PostgreSQL では id 3・9・12 が、1ページ目と2ページ目の両方に出ています。 そのぶん、priority が 1 なのにどちらのページにも出ない行があります(MySQL では 18・21・24、PostgreSQL では 21・24・27)。

PostgreSQL で EXPLAIN ANALYZE を見ると、LIMIT の有無で並べ替えの方法が切り替わっていました。

-- ORDER BY priority LIMIT 5
Sort Method: top-N heapsort  Memory: 25kB
-- ORDER BY priority
Sort Method: quicksort  Memory: 25kB

LIMIT が付くと「上位N件だけを取り出す」方法が選ばれ、同じ値の行のどれが選ばれるかが変わります。SQLite 3.45.1 では同じデータで重複は起きませんでしたが、これは今回のデータと実行計画でたまたま id の順になっただけで、順番が約束されているわけではありません。

直し方:最後に一意な列を足す

ORDER BY の最後に、主キーなどの一意な列を足します。 これで全行の順番が1通りに決まり、ページをまたいでも重複や抜けが起きません。

-- 直す前:同じ priority の行の順番が決まらない
SELECT id, title FROM tasks ORDER BY priority LIMIT 5 OFFSET 5;

-- 直した後:id を足して順番を1通りにする
SELECT id, title FROM tasks ORDER BY priority, id LIMIT 5 OFFSET 5;
確認項目 見るところ
ORDER BY の列だけで行が1つに決まるか 作成日時・ステータス・優先度などは同じ値が並びやすい
最後に主キー(一意な列)を足したか ORDER BY created_at DESC, id DESC のように向きもそろえる
ORDER BY なしで LIMIT していないか ORDER BY がないSELECTは、返る順番そのものが決まっていない

OFFSET を使う方式と、最後に見た行のキーから続きを取る方式(cursor)の比較は、既存記事「ページネーションのoffsetとcursorの違い」で扱っています。cursor 方式でも「並べ替えの最後に一意な列を置く」という考え方は同じです。

インデックスで並べ替えを省く(EXPLAINでfilesortを確認)

インデックスは値の順に並んでいるので、ORDER BY の並びとインデックスの並びが合えば、データベースは並べ替え処理を省いてインデックスの順に読むだけで済みます。 合わないときは、MySQL の EXPLAIN の Extra 列に Using filesort(取り出した行を並べ替える処理)と表示されます。

10万行の orders テーブルに (member_id, amount) の複合インデックスを作って確認します。

-- MySQL 8.0
SET SESSION cte_max_recursion_depth = 200000;
CREATE TABLE orders (
  id INT PRIMARY KEY AUTO_INCREMENT,
  member_id INT NOT NULL,
  amount INT,
  created_at DATETIME NOT NULL,
  note VARCHAR(50)
);
INSERT INTO orders (member_id, amount, created_at, note)
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 100000)
SELECT n % 1000, IF(n % 10 = 0, NULL, (n * 37) % 5000),
       TIMESTAMPADD(MINUTE, n, '2026-01-01'), 'x' FROM s;
CREATE INDEX idx_member_amount ON orders (member_id, amount);
ANALYZE TABLE orders;

EXPLAIN SELECT * FROM orders ... の結果(MySQL 8.0.46)を、要点の列だけ抜き出しました。

No ORDER BY/WHERE type key rows Extra
1 ORDER BY member_id, amount LIMIT 10 index idx_member_amount 10 NULL
2 ORDER BY member_id DESC, amount DESC LIMIT 10 index idx_member_amount 10 Backward index scan
3 ORDER BY member_id ASC, amount DESC LIMIT 10 ALL NULL 97776 Using filesort
4 ORDER BY amount LIMIT 10 ALL NULL 97776 Using filesort
5 WHERE member_id = 7 ORDER BY amount ref idx_member_amount 100 NULL
6 WHERE member_id = 7 ORDER BY amount DESC ref idx_member_amount 100 Backward index scan
7 WHERE member_id = 7 ORDER BY created_at ref idx_member_amount 100 Using filesort
8 WHERE member_id = 7 ORDER BY amount IS NULL, amount ref idx_member_amount 100 Using filesort
9 ORDER BY member_id + 0, amount LIMIT 10 ALL NULL 97776 Using filesort

この表から、並べ替えにインデックスが使われる条件が見えてきます。

  • インデックスと同じ列順・同じ向き(No.1)なら、10行読むだけで終わります。
  • 全部の列が逆向き(No.2)なら、インデックスを後ろから読みます(Backward index scan)。
  • 向きが列ごとに違う(No.3)と、インデックスの順とは合わなくなり、全行を読んで並べ替えます。
  • インデックスの2列目だけ(No.4)では、並びが合いません。ただし 1列目を = で絞れば(No.5・6)、残った行は2列目の順に並んでいるので並べ替えが省かれます。
  • 列を式で包む(No.8・9)と、インデックスの順は使われません。NULL の位置を変える amount IS NULL, amount も式なので filesort になりました。No.8 は絞り込んだ100行だけの並べ替えで済んでいる点も読み取れます。

昇順と降順をまぜるなら降順インデックス

MySQL 8.0 以降は、インデックスの列ごとに向きを指定できます(降順インデックス)。8.4/9.7 でも同じ構文で使えます。

CREATE INDEX idx_member_amount_desc ON orders (member_id ASC, amount DESC);
ORDER BY type key rows Extra
ORDER BY member_id ASC, amount DESC LIMIT 10 index idx_member_amount_desc 10 NULL
ORDER BY member_id DESC, amount ASC LIMIT 10 index idx_member_amount_desc 10 Backward index scan

No.3 で filesort になっていたSQLが、10行を読むだけになりました。「ASC, DESC」のインデックスは、ちょうど逆向きの「DESC, ASC」にも後ろから読む形で使えます。

PostgreSQLではNULLの位置もインデックスと合わせる

PostgreSQL 16.15 で同じデータ・同じインデックスを作り、EXPLAIN (COSTS OFF) で確認しました。

ORDER BY 実行計画(要点)
member_id, amount LIMIT 10 Index Scan using idx_member_amount
member_id DESC, amount DESC LIMIT 10 Index Scan Backward using idx_member_amount
member_id, amount DESC LIMIT 10 Incremental Sort(Presorted Key: member_id)
member_id DESC, amount DESC NULLS LAST LIMIT 10 Incremental Sort(Presorted Key: member_id)
amount LIMIT 10 Sort + Seq Scan

4行目に注目してください。向きはインデックスを逆から読めば合うのに、NULLS LAST を付けたことで並びが合わなくなり、並べ替えが入っています。 PostgreSQL のインデックスは既定で「昇順・NULL末尾」なので、逆から読むと「降順・NULL先頭」になります。「降順・NULL末尾」で読みたいときは、その並びのインデックスを作ります。

CREATE INDEX idx_member_amount_desc
  ON orders (member_id, amount DESC NULLS LAST);

EXPLAIN (COSTS OFF)
SELECT * FROM orders ORDER BY member_id, amount DESC NULLS LAST LIMIT 10;
 Limit
   ->  Index Scan using idx_member_amount_desc on orders

Incremental Sort は「1列目はすでに並んでいるので、同じ member_id のかたまりごとに2列目だけ並べ替える」方法で、全体を並べ替えるより軽く済みます。

SQLite 3.45.1 でも EXPLAIN QUERY PLAN で同じ傾向を確認できました。(category, price) のインデックスに対して ORDER BY category, price は SCAN items USING INDEX ix だけ、ORDER BY category, price DESC では USE TEMP B-TREE FOR RIGHT PART OF ORDER BY が追加されました。

並べ替えとインデックスの確認表

確認項目 合っているとき 合わないときの直し方
列の順番 インデックスの左から順に使っている インデックスの列順を ORDER BY に合わせる
途中の列を飛ばしていないか 飛ばした列は WHERE の = で固定している = で絞る列をインデックスの左に置く
向き(ASC/DESC) 全列同じ、または全列逆 列ごとに向きを指定したインデックスを作る
NULLの位置(PostgreSQL) インデックスの定義と同じ、または全部逆 NULLS LAST 付きのインデックスを作る
式・関数 列をそのまま書いている 式インデックス(関数インデックス)を検討する

EXPLAIN の読み方全般や、インデックスが使われない条件は、既存記事「SQLのインデックスが効かない10のパターンと確認方法」で詳しく扱っています。

ORDER BYでつまずきやすい点と直し方

症状 原因 直し方
DBを変えたらNULLの位置が変わった NULLの既定位置がDBMSで逆 CASE WHEN 列 IS NULL THEN 1 ELSE 0 END を先頭に置く
MySQL で NULLS LAST が構文エラー MySQL の構文に含まれていない ORDER BY 列 IS NULL, 列
両方降順のつもりが片方だけ昇順 DESC は直前の列だけに効く 各列に DESC を付ける
1, 10, 100, 2 と並ぶ 文字列型は1文字ずつ比較される CAST で数値に変換、または列を数値型にする
ページをめくると同じ行が出る 同じ値の行の順番が決まっていない 最後に一意な列(id など)を足す
ひらがなとカタカナが混ざって並ぶ 照合順序が同じ文字として扱っている COLLATE で日本語向けの照合順序を指定する
PostgreSQL で別名を使った式がエラー 別名は単独でしか使えない 元の式を書く、またはサブクエリで包む
サブクエリ内の ORDER BY が効かない 外側のSELECTの順番は外側の ORDER BY で決まる いちばん外側に ORDER BY を書く
件数が増えたら遅くなった 全行を読んで並べ替えている EXPLAIN で確認し、ORDER BY に合うインデックスを作る

よくある質問

Q. ORDER BY を書かなかったら、どの順番で返りますか? A. 順番は決まっていません。主キーの順に見えることが多いのですが、使われるインデックスや実行計画が変わると順番も変わります。SQL Server の公式ドキュメントにも、ORDER BY を指定しない限り行の順番は保証されないと明記されています。順番に意味がある処理では、必ず ORDER BY を書きます。

Q. 複数列の ORDER BY で、2列目以降はいつ使われますか? A. 1列目の値が同じ行どうしの順番を決めるときだけ使われます。1列目がすべて違う値なら、2列目以降は結果に影響しません。逆に、1列目に同じ値が多いほど2列目以降が効いてきます。

Q. NULL を末尾にする書き方で、どのDBでも動くものはどれですか? A. ORDER BY CASE WHEN 列 IS NULL THEN 1 ELSE 0 END, 列 です。MySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1 で同じ結果になることを確認しました。CASE 式は標準SQLなので、Oracle や SQL Server でも同じ書き方が使えます(こちらは未実行で、公式ドキュメントの構文に基づきます)。

Q. ORDER BY と LIMIT を一緒に使うとき、気をつけることはありますか? A. 並べ替えの列に同じ値があるなら、最後に一意な列を足してください。この記事の再現では、MySQL と PostgreSQL の両方で、1ページ目と2ページ目に同じ行が出ました。ORDER BY priority, id のように主キーを足すと、どちらも同じ結果にそろいました。

まとめ

  • 複数列は ORDER BY 列1, 列2 とカンマで区切り、左の列が優先。ASC/DESC は列ごとに付ける
  • NULL の位置は DBMS で逆。MySQL・SQLite・SQL Server は昇順で先頭、PostgreSQL・Oracle は昇順で末尾
  • NULLS FIRST/NULLS LAST は PostgreSQL・SQLite・Oracle で使える。MySQL と SQL Server では CASE WHEN 列 IS NULL ... を先頭に置く
  • 任意の順番は CASE 式で数字に変換する。MySQL は FIELD()、PostgreSQL は array_position() も使える
  • 文字列の数字は 1, 10, 2 の順になる。数値に変換して並べるか、列を数値型にする
  • 同じ値の行の順番は決まっていない。LIMIT と組み合わせるときは最後に一意な列を足す
  • インデックスと列順・向き(PostgreSQL は NULL の位置も)を合わせると、並べ替え処理を省ける。EXPLAIN で Using filesort の有無を確認する

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

(記載例)出典:株式会社RJC「SQL ORDER BY 複数列の昇順・降順とNULLの順番まとめ」

株式会社RJCでは、SQLの並べ替えやNULLの扱いのような基礎を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?