- SQL
SQL 自己結合とは|同じテーブルをJOINする書き方と上司部下・前行比較
自己結合(セルフジョイン)とは、1つのテーブルに別名を2つ付けて、別々のテーブルのようにJOINする書き方です。 専用の構文があるわけではなく、ふつうの INNER JOIN や LEFT JOIN の両側に同じテーブルを置きます。「社員と、その上司」のように、同じテーブルの行と行を見比べたいときに使います。
この記事では、上司と部下・前行との比較・重複の検出・組み合わせの生成という4つのパターンを、そのまま貼れる CREATE TABLE と INSERT、実行結果つきで紹介します。INNER と LEFT で最上位の行が消える違い、別名を付け忘れたときのエラー文も再現しました。前行比較は LAG、多段の階層は再帰CTE と書き比べ、どちらを選ぶかの判断表にまとめています。実行環境は PostgreSQL 16.15 と MySQL 8.0.46 です。
自己結合の早見表
| パターン | 結合条件(ONの書き方) | 何が取れるか |
|---|---|---|
| 上司と部下 | e.manager_id = m.id |
各社員の行に、上司の行を横に並べる |
| 前行との比較 | p.order_date = c.order_date - 1 |
当日の行に、前日の行を横に並べる |
| 重複の検出 | a.email = b.email AND a.id < b.id |
同じ値を持つ行のペア |
| 組み合わせの生成 | a.id < b.id |
重複のない2つ組(総当たり表) |
書くときの決まりごとは次の3つです。
| 決まりごと | 内容 |
|---|---|
| 別名を付ける | 同じテーブル名が2回出るので、employees e と employees m のように区別する |
| 列は別名付きで書く | e.name、m.name のように、どちら側の列かを必ず示す |
| 相手がいない行を残すなら LEFT JOIN | INNER JOIN では、相手のいない行(最上位の人など)が結果から外れる |
自己結合と、似た目的で使える書き方の選び分けです。
| やりたいこと | まず選ぶ書き方 |
|---|---|
| 1段上(直属の上司)を並べる | 自己結合 |
| 2段上までなど、段数が決まっている | 自己結合を段数ぶん重ねる |
| 段数が決まっていない階層を全部たどる | 再帰CTE |
| 1つ前・1つ後の行と比べる | LAG・LEAD |
| 同じ値を持つ行のペアを出す | 自己結合 |
| 重複している値と件数だけ知りたい | GROUP BY と HAVING |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL 自己結合とは|同じテーブルをJOINする書き方と上司部下・前行比較」
検証環境とサンプルデータ(コピペ用)
| 項目 | 内容 |
|---|---|
| PostgreSQL | 16.15 |
| MySQL | 8.0.46 |
| Oracle・SQL Server | 未実行 |
4つのパターンで使うテーブルをまとめて作ります。PostgreSQL・MySQL のどちらにもそのまま貼れます。
-- パターン1:上司と部下
DROP TABLE IF EXISTS employees;
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(20) NOT NULL,
dept VARCHAR(20) NOT NULL,
manager_id INT
);
INSERT INTO employees (id, name, dept, manager_id) VALUES
(1, '佐藤', '本部', NULL),
(2, '鈴木', '開発', 1),
(3, '高橋', '営業', 1),
(4, '田中', '開発', 2),
(5, '伊藤', '開発', 2),
(6, '渡辺', '営業', 3),
(7, '山本', '開発', 4);
-- パターン2:前行との比較(9月4日の行は無い)
DROP TABLE IF EXISTS daily_orders;
CREATE TABLE daily_orders (
order_date DATE PRIMARY KEY,
amount INT NOT NULL
);
INSERT INTO daily_orders (order_date, amount) VALUES
('2026-09-01', 120),
('2026-09-02', 150),
('2026-09-03', 130),
('2026-09-05', 180),
('2026-09-06', 170);
-- パターン3:重複の検出
DROP TABLE IF EXISTS members;
CREATE TABLE members (
id INT PRIMARY KEY,
name VARCHAR(20) NOT NULL,
email VARCHAR(50) NOT NULL
);
INSERT INTO members (id, name, email) VALUES
(1, '青木', 'aoki@example.com'),
(2, '石井', 'ishii@example.com'),
(3, '青木(再登録)', 'aoki@example.com'),
(4, '上田', 'ueda@example.com'),
(5, '石井2', 'ishii@example.com'),
(6, '青木3', 'aoki@example.com');
-- パターン4:組み合わせの生成
DROP TABLE IF EXISTS teams;
CREATE TABLE teams (
id INT PRIMARY KEY,
name VARCHAR(10) NOT NULL
);
INSERT INTO teams (id, name) VALUES (1, '赤'), (2, '青'), (3, '緑'), (4, '黄');
employees は、manager_id に上司の id が入る形です。組織の形は次のようになっています。
graph TD
A["1 佐藤(本部)"] --> B["2 鈴木(開発)"]
A --> C["3 高橋(営業)"]
B --> D["4 田中(開発)"]
B --> E["5 伊藤(開発)"]
C --> F["6 渡辺(営業)"]
D --> G["7 山本(開発)"]
自己結合の基本の形
同じテーブルを、役割の違う2つのテーブルだと考えます。 employees を「社員として見る側(e)」と「上司として見る側(m)」に分けて、e.manager_id = m.id でつなぎます。
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees e
INNER JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;
| 部分 | 意味 |
|---|---|
employees e |
社員として見る側。1行が1人の社員 |
employees m |
上司として見る側。中身は同じテーブル |
e.manager_id = m.id |
社員の「上司のid」と、上司側の「id」が一致する行同士を並べる |
e.name、m.name |
同じ列名なので、別名で区別し、AS で見出しも分ける |
別名は e と m のほか、emp と mgr、child と parent など、役割が伝わる名前にすると読みやすくなります。
パターン1:上司と部下を1行に並べる
INNER JOIN:最上位の行が消える(6行)
上のSQLの実行結果(PostgreSQL 16.15)です。
id | employee | manager
----+----------+---------
2 | 鈴木 | 佐藤
3 | 高橋 | 佐藤
4 | 田中 | 鈴木
5 | 伊藤 | 鈴木
6 | 渡辺 | 高橋
7 | 山本 | 田中
(6 rows)
社員は7人いますが、結果は6行です。佐藤は manager_id が NULL で、結合する相手がいないため、INNER JOIN では結果から外れます。
LEFT JOIN:最上位の行も残る(7行)
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;
id | employee | manager
----+----------+---------
1 | 佐藤 |
2 | 鈴木 | 佐藤
3 | 高橋 | 佐藤
4 | 田中 | 鈴木
5 | 伊藤 | 鈴木
6 | 渡辺 | 高橋
7 | 山本 | 田中
(7 rows)
LEFT JOIN は左側(e)の行をすべて残し、相手がいなければ右側の列を NULL にします。MySQL 8.0.46 でも同じ6行と7行でした。
| JOINの種類 | 行数 | 佐藤(上司なし)の行 |
|---|---|---|
| INNER JOIN | 6行 | 外れる |
| LEFT JOIN | 7行 | 残る(manager は NULL) |
NULL の代わりに文字を出したいときは COALESCE を使います。
SELECT e.name AS employee, COALESCE(m.name, '(なし)') AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;
1行目が 佐藤 | (なし) になり、ほかの行は変わりません。
上司ごとに部下の人数を数える
今度は上司側(m)を左に置き、部下側(e)を LEFT JOIN します。
SELECT m.name AS manager, COUNT(e.id) AS members
FROM employees m
LEFT JOIN employees e ON e.manager_id = m.id
GROUP BY m.id, m.name
ORDER BY m.id;
manager | members
---------+---------
佐藤 | 2
鈴木 | 2
高橋 | 1
田中 | 1
伊藤 | 0
渡辺 | 0
山本 | 0
(7 rows)
COUNT(e.id) と列を指定しているので、部下のいない人は 0 になります。同じ結合に WHERE e.id IS NULL を付けると、部下のいない人(伊藤・渡辺・山本)だけを取り出せます。
2段上まで並べる(自己結合を重ねる)
段数が決まっているなら、自己結合をもう1回重ねます。
SELECT e.name AS employee, m.name AS manager, g.name AS grand_manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
LEFT JOIN employees g ON m.manager_id = g.id
ORDER BY e.id;
employee | manager | grand_manager
----------+---------+---------------
佐藤 | |
鈴木 | 佐藤 |
高橋 | 佐藤 |
田中 | 鈴木 | 佐藤
伊藤 | 鈴木 | 佐藤
渡辺 | 高橋 | 佐藤
山本 | 田中 | 鈴木
(7 rows)
山本から見ると3段上に佐藤がいますが、この書き方では2段上までしか届きません。段数が決まっていない階層は、後半の再帰CTEで扱います。
ONの向きを逆にすると「部下の一覧」になる
結合条件の左右を取り違えると、エラーにはならず、意味の違う結果が返ります。
SELECT e.name AS employee, m.name AS manager
FROM employees e
JOIN employees m ON e.id = m.manager_id
ORDER BY e.id, m.id;
employee | manager
----------+---------
佐藤 | 鈴木
佐藤 | 高橋
鈴木 | 田中
鈴木 | 伊藤
高橋 | 渡辺
田中 | 山本
(6 rows)
manager の見出しの下に部下の名前が並んでいます。「どちらの別名が、どちらの役割か」を先に決めてから ON を書くと取り違えを防げます。「社員側の manager_id = 上司側の id」と声に出して読める形になっていれば合っています。
別名を付け忘れたとき・列があいまいなときのエラー
別名なしで同じテーブルを2回書いた場合
SELECT * FROM employees JOIN employees ON employees.manager_id = employees.id;
| DB | エラー文 |
|---|---|
| PostgreSQL 16.15 | ERROR: table name "employees" specified more than once |
| MySQL 8.0.46 | ERROR 1066 (42000): Not unique table/alias: 'employees' |
同じ名前のテーブルが2つあると区別が付かないため、どちらのDBも実行前に止まります。
列名に別名を付けなかった場合
SELECT name FROM employees e JOIN employees m ON e.manager_id = m.id;
| DB | エラー文 |
|---|---|
| PostgreSQL 16.15 | ERROR: column reference "name" is ambiguous |
| MySQL 8.0.46 | ERROR 1052 (23000): Column 'name' in field list is ambiguous |
自己結合では両側に同じ列名があるので、SELECT・ON・WHERE・ORDER BY のすべてで e.name のように別名を付けます。
片方だけに別名を付けた場合
SELECT employees.name, m.name
FROM employees JOIN employees m ON employees.manager_id = m.id
ORDER BY employees.id;
これは PostgreSQL・MySQL とも実行でき、6行が返りました。片方がテーブル名のままでも区別は付くためです。ただし、どちらが社員でどちらが上司かが読み取りにくいので、両側に役割の分かる別名を付けておくのがおすすめです。
| 直す前 | 直した後 |
|---|---|
FROM employees JOIN employees ON ... |
FROM employees e JOIN employees m ON ... |
SELECT name |
SELECT e.name AS employee, m.name AS manager |
ON e.id = m.manager_id(役割が逆) |
ON e.manager_id = m.id |
INNER JOIN(最上位が外れる) |
LEFT JOIN |
パターン2:前の行と比較する(自己結合とLAG)
日ごとの注文数 daily_orders で、前日との差を出します。データには9月4日の行がありません。 この抜けが、書き方による違いを生みます。
自己結合で「1日前の行」を並べる
日付の引き算の書き方がDBで違うので、2通り載せます。
-- PostgreSQL
SELECT c.order_date, c.amount, p.amount AS prev_amount, c.amount - p.amount AS diff
FROM daily_orders c
LEFT JOIN daily_orders p ON p.order_date = c.order_date - 1
ORDER BY c.order_date;
-- MySQL
SELECT c.order_date, c.amount, p.amount AS prev_amount, c.amount - p.amount AS diff
FROM daily_orders c
LEFT JOIN daily_orders p ON p.order_date = c.order_date - INTERVAL 1 DAY
ORDER BY c.order_date;
order_date | amount | prev_amount | diff
------------+--------+-------------+------
2026-09-01 | 120 | |
2026-09-02 | 150 | 120 | 30
2026-09-03 | 130 | 150 | -20
2026-09-05 | 180 | |
2026-09-06 | 170 | 180 | -10
(5 rows)
9月5日の行は、前日(9月4日)の行が無いので NULL になりました。これは「暦のうえでの前日と比べる」動きです。MySQL 8.0.46 でも同じ結果でした。
LAGで「1つ前の行」を取る
LAG は、並べ替えた順で1つ前の行の値を返すウィンドウ関数です。
SELECT order_date, amount,
LAG(amount) OVER (ORDER BY order_date) AS prev_amount,
amount - LAG(amount) OVER (ORDER BY order_date) AS diff
FROM daily_orders
ORDER BY order_date;
order_date | amount | prev_amount | diff
------------+--------+-------------+------
2026-09-01 | 120 | |
2026-09-02 | 150 | 120 | 30
2026-09-03 | 130 | 150 | -20
2026-09-05 | 180 | 130 | 50
2026-09-06 | 170 | 180 | -10
(5 rows)
9月5日の行には、1つ前の行である9月3日の 130 が入りました。こちらは「データがある直前の行と比べる」動きです。
| 比べ方 | 9月5日の prev_amount | 意味 |
|---|---|---|
| 自己結合(日付 − 1日) | NULL | 暦の前日と比べる。抜けた日は「比較なし」 |
| LAG | 130 | 並び順で1つ前の行と比べる。抜けは気にしない |
どちらが合っているかは、求めたい内容で決まります。 「前日比」なら自己結合の結果が、「前回比」なら LAG の結果が合います。
LAG の第2引数は何行前かを、第3引数は前の行が無いときの値を指定します。LAG(amount, 1, 0) と書くと、先頭行の値は NULL ではなく 0 になりました。
自己結合で「1つ前の行」を取りたいとき
LAG が使えない環境で前回比を出したい場合は、ROW_NUMBER で連番を振ってから「連番 − 1」で自己結合します。
WITH numbered AS (
SELECT order_date, amount,
ROW_NUMBER() OVER (ORDER BY order_date) AS rn
FROM daily_orders
)
SELECT c.order_date, c.amount, p.amount AS prev_amount, c.amount - p.amount AS diff
FROM numbered c
LEFT JOIN numbered p ON p.rn = c.rn - 1
ORDER BY c.order_date;
結果は LAG 版と同じで、9月5日の prev_amount は 130 でした。ウィンドウ関数そのものが無い環境では、相関サブクエリで「自分より前の日付の最大値」を探して結合します。
SELECT c.order_date, c.amount, p.amount AS prev_amount
FROM daily_orders c
LEFT JOIN daily_orders p
ON p.order_date = (SELECT MAX(x.order_date) FROM daily_orders x
WHERE x.order_date < c.order_date)
ORDER BY c.order_date;
こちらも LAG 版と同じ値(9月5日は 130)になることを、PostgreSQL・MySQL で確認しました。
等号以外の条件でも結合できる
自己結合の ON には、= 以外の比較も書けます。「自分より前の日で、自分より注文数が多かった日」を並べる例です。
SELECT c.order_date, c.amount, p.amount AS prev_amount
FROM daily_orders c
JOIN daily_orders p ON p.order_date < c.order_date AND p.amount > c.amount
ORDER BY c.order_date, p.order_date;
order_date | amount | prev_amount
------------+--------+-------------
2026-09-03 | 130 | 150
2026-09-06 | 170 | 180
(2 rows)
パターン3:重複している行のペアを見つける
同じメールアドレスで登録された会員を探します。同じ値の行同士を結び、a.id < b.id で「自分自身」と「左右が入れ替わっただけのペア」を除きます。
SELECT a.id AS id_a, a.name AS name_a, b.id AS id_b, b.name AS name_b, a.email
FROM members a
JOIN members b ON a.email = b.email AND a.id < b.id
ORDER BY a.id, b.id;
id_a | name_a | id_b | name_b | email
------+--------------+------+--------------+-------------------
1 | 青木 | 3 | 青木(再登録) | aoki@example.com
1 | 青木 | 6 | 青木3 | aoki@example.com
2 | 石井 | 5 | 石井2 | ishii@example.com
3 | 青木(再登録) | 6 | 青木3 | aoki@example.com
(4 rows)
a.id < b.id の部分を変えると、行数が次のように変わります。
| ONの条件 | 行数 | 含まれるもの |
|---|---|---|
a.email = b.email だけ |
14行 | 自分自身とのペア、左右が入れ替わったペアも含む |
a.email = b.email AND a.id <> b.id |
8行 | 左右が入れ替わったペアが両方入る |
a.email = b.email AND a.id < b.id |
4行 | 重複のないペアだけ |
最初の1件を残して、それ以外を一覧にする
「同じメールアドレスで、自分より小さい id の行がある」行が、後から登録された側です。
SELECT b.id, b.name, b.email
FROM members b
WHERE EXISTS (SELECT 1 FROM members a WHERE a.email = b.email AND a.id < b.id)
ORDER BY b.id;
id | name | email
----+--------------+-------------------
3 | 青木(再登録) | aoki@example.com
5 | 石井2 | ishii@example.com
6 | 青木3 | aoki@example.com
(3 rows)
整理の対象を決める前に、この SELECT で対象の行を確かめておくと進めやすくなります。
件数だけ知りたいなら GROUP BY
SELECT email, COUNT(*) AS cnt, MIN(id) AS first_id
FROM members
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY email;
email | cnt | first_id
-------------------+-----+----------
aoki@example.com | 3 | 1
ishii@example.com | 2 | 2
(2 rows)
| 知りたいこと | 書き方 |
|---|---|
| どの値が何件重複しているか | GROUP BY と HAVING |
| 重複している行同士の中身を見比べたい | 自己結合(a.id < b.id) |
| 残す1件以外を一覧にしたい | EXISTS、または ROW_NUMBER で2番目以降 |
パターン4:組み合わせ(総当たり表)を作る
4チームの対戦表を作ります。結合条件に a.id < b.id だけを書くと、重複のない2つ組が得られます。
SELECT a.name AS team_a, b.name AS team_b
FROM teams a
JOIN teams b ON a.id < b.id
ORDER BY a.id, b.id;
team_a | team_b
--------+--------
赤 | 青
赤 | 緑
赤 | 黄
青 | 緑
青 | 黄
緑 | 黄
(6 rows)
条件を変えると、取れる組の種類が変わります。
| 書き方 | 行数(4チーム) | 意味 |
|---|---|---|
CROSS JOIN(条件なし) |
16行 | 自分自身との組も含むすべて |
ON a.id <> b.id |
12行 | 順番を区別する組(赤-青 と 青-赤 は別) |
ON a.id < b.id |
6行 | 順番を区別しない組(総当たり) |
自己結合を重ねると3つ組も作れます。
SELECT a.name AS t1, b.name AS t2, c.name AS t3
FROM teams a
JOIN teams b ON a.id < b.id
JOIN teams c ON b.id < c.id
ORDER BY a.id, b.id, c.id;
4チームから3つを選ぶ組み合わせで、4行(赤-青-緑、赤-青-黄、赤-緑-黄、青-緑-黄)が返りました。組み合わせの行数は元の行数の2乗に近い勢いで増えるので、行数の多いテーブルでは WHERE で対象を絞ってから結合します。
同じ考え方で、同じ部署に所属する社員同士のペアも出せます。
SELECT a.dept, a.name AS member_a, b.name AS member_b
FROM employees a
JOIN employees b ON a.dept = b.dept AND a.id < b.id
ORDER BY a.dept, a.id, b.id;
営業が1組(高橋-渡辺)、開発が6組の合計7行でした。
多段の階層は再帰CTEでたどる
再帰CTEは、「自分自身の結果にもう一度結合する」ことを、行が増えなくなるまで繰り返す書き方です。 自己結合を何回重ねればよいか分からない階層に向いています。
最上位から全員をたどる
文字列の連結と型変換の書き方がDBで違うので、2通り載せます。
-- PostgreSQL
WITH RECURSIVE tree AS (
SELECT id, name, manager_id, 1 AS depth, CAST(name AS TEXT) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, t.depth + 1, t.path || ' > ' || e.name
FROM employees e
JOIN tree t ON e.manager_id = t.id
)
SELECT id, name, depth, path FROM tree ORDER BY path;
-- MySQL
WITH RECURSIVE tree AS (
SELECT id, name, manager_id, 1 AS depth, CAST(name AS CHAR(200)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, t.depth + 1,
CAST(CONCAT(t.path, ' > ', e.name) AS CHAR(200))
FROM employees e
JOIN tree t ON e.manager_id = t.id
)
SELECT id, name, depth, path FROM tree ORDER BY path;
id | name | depth | path
----+------+-------+---------------------------
1 | 佐藤 | 1 | 佐藤
2 | 鈴木 | 2 | 佐藤 > 鈴木
5 | 伊藤 | 3 | 佐藤 > 鈴木 > 伊藤
4 | 田中 | 3 | 佐藤 > 鈴木 > 田中
7 | 山本 | 4 | 佐藤 > 鈴木 > 田中 > 山本
3 | 高橋 | 2 | 佐藤 > 高橋
6 | 渡辺 | 3 | 佐藤 > 高橋 > 渡辺
(7 rows)
| 部分 | 役割 |
|---|---|
| UNION ALL の上(起点) | 最上位の行(manager_id IS NULL)を取る |
| UNION ALL の下(繰り返し) | 直前に取れた行(tree)を上司として、その部下を足す |
depth |
何段目かを数える列 |
path |
最上位からの道のり。並べ替えにも使える |
繰り返しの部分は employees e JOIN tree t ON e.manager_id = t.id で、形は自己結合そのものです。相手が「テーブル全体」ではなく「直前の結果」になっている点だけが違います。
MySQL で path を CAST(... AS CHAR(200)) としているのは、起点の行で列の長さが決まるためです。PostgreSQL で同じ CHAR(200) を使うと、空白で埋められた固定長になり、path が伸びなくなりました。PostgreSQL では TEXT にします。
ある人の配下を全員取る・上にたどる
起点の条件を変えると、部分的な階層を取れます。
-- 鈴木(id=2)とその配下
WITH RECURSIVE sub AS (
SELECT id, name, manager_id, 0 AS level FROM employees WHERE id = 2
UNION ALL
SELECT e.id, e.name, e.manager_id, s.level + 1
FROM employees e JOIN sub s ON e.manager_id = s.id
)
SELECT id, name, level FROM sub ORDER BY level, id;
id | name | level
----+------+-------
2 | 鈴木 | 0
4 | 田中 | 1
5 | 伊藤 | 1
7 | 山本 | 2
(4 rows)
結合条件の向きを e.id = u.manager_id に変えると、上に向かってたどれます。
-- 山本(id=7)から最上位まで
WITH RECURSIVE up AS (
SELECT id, name, manager_id, 0 AS level FROM employees WHERE id = 7
UNION ALL
SELECT e.id, e.name, e.manager_id, u.level + 1
FROM employees e JOIN up u ON e.id = u.manager_id
)
SELECT id, name, level FROM up ORDER BY level;
id | name | level
----+------+-------
7 | 山本 | 0
4 | 田中 | 1
2 | 鈴木 | 2
1 | 佐藤 | 3
(4 rows)
RECURSIVE を書き忘れた場合
WITH tree AS (
SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id FROM employees e JOIN tree t ON e.manager_id = t.id
)
SELECT * FROM tree;
| DB | エラー文 |
|---|---|
| PostgreSQL 16.15 | ERROR: relation "tree" does not exist(HINT: Use WITH RECURSIVE, or re-order the WITH items to remove forward references.) |
| MySQL 8.0.46 | ERROR 1146 (42S02): Table 't210.tree' doesn't exist(t210 は今回のデータベース名) |
データが輪になっているとき
上司をたどると自分に戻ってくるデータ(佐藤の上司を山本にした状態)で再帰CTEを実行すると、繰り返しが終わりません。トランザクションの中で一時的にデータを変えて確かめ、ROLLBACK で戻しました。
| DB | 起きたこと |
|---|---|
| MySQL 8.0.46 | ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value. で止まる(既定値は 1000) |
| PostgreSQL 16.15 | 回数の上限が無く、statement_timeout を2秒に設定して ERROR: canceling statement due to statement timeout で止めた |
PostgreSQL 16.15 では、CTE の後ろに CYCLE id SET is_cycle USING visited を付けると、同じ id に戻った時点で is_cycle が t の行を1つ出して止まることを確認しました。どのDBでも使える備えとして、繰り返しの側に WHERE t.depth < 20 のような段数の上限を書いておく方法があります。輪になったデータでも、PostgreSQL・MySQL とも20段目で止まることを確認しました。
自己結合・LAG・再帰CTEの判断表
| 場面 | 自己結合 | LAG・LEAD | 再帰CTE |
|---|---|---|---|
| 直属の上司を並べる | 合う | - | 書けるが大がかり |
| 2〜3段など決まった段数 | 合う(重ねる) | - | 合う |
| 段数が分からない階層 | 段数ぶんしか届かない | - | 合う |
| 配下の全員・最上位までの道のり | 届かない | - | 合う |
| 1つ前の行と比べる(前回比) | 連番を振れば書ける | 合う | - |
| 暦の前日・前月と比べる(抜けは比較なし) | 合う | 抜けがあると別の日と比べる | - |
| 同じ値を持つ行のペア | 合う | - | - |
| 総当たりの組み合わせ | 合う | - | - |
| ウィンドウ関数やCTEが無い環境 | 合う | 使えない | 使えない |
迷ったときは、次の順で考えると選びやすくなります。
- 比べる相手は「並び順で隣の行」か → そうなら LAG・LEAD。
- 親子をたどる段数は決まっているか → 決まっていれば自己結合、決まっていなければ再帰CTE。
- 行と行のペアそのものが欲しいか → 自己結合。
自己結合を書くときのチェックリスト
| 確認すること | 理由 |
|---|---|
| 両側に役割の分かる別名を付けたか | 別名が無いと specified more than once / Not unique table/alias で止まる |
| すべての列に別名を付けたか | 付けないと ambiguous で止まる |
| ON の左右が役割と合っているか | 逆でもエラーにならず、意味の違う結果になる |
| 相手のいない行を残したいか | 残すなら LEFT JOIN |
ペアを出すとき a.id < b.id を入れたか |
入れないと自分自身や入れ替わったペアが混ざる |
| 結合に使う列にインデックスがあるか | manager_id のような列は、行数が増えたときに結合の助けになる |
よくある質問
Q. 自己結合には専用のキーワードがありますか?
A. ありません。SELF JOIN という構文は無く、通常の JOIN の両側に同じテーブルを置き、別名で区別します。INNER JOIN・LEFT JOIN・CROSS JOIN のどれでも自己結合にできます。
Q. 自己結合すると、上司のいない人が結果から消えました。なぜですか?
A. INNER JOIN は、結合する相手がいる行だけを返すためです。manager_id が NULL の行は相手がいないので外れます。LEFT JOIN に変えると、上司の列が NULL の状態で残ります(今回は6行が7行になりました)。
Q. 自己結合とサブクエリはどちらがよいですか? A. 相手の行の列を横に並べて表示したいなら自己結合、相手が存在するかどうかだけを知りたいなら EXISTS のサブクエリが合います。重複の一覧では、ペアを見比べるなら自己結合、対象の行だけ欲しいなら EXISTS を使いました。
Q. 前の行との差は、自己結合と LAG のどちらで書くべきですか? A. 「並び順で1つ前」なら LAG が短く書けます。「暦のうえで1日前」と比べたいなら、日付で自己結合します。今回のデータでは、抜けた日の次の行で、自己結合は NULL、LAG は 130 と結果が分かれました。
まとめ
- 自己結合は、同じテーブルに別名を2つ付けてJOINする書き方。専用の構文は無く、ふつうの JOIN を使います。
- 別名と列の修飾が必須。忘れると
specified more than once、Not unique table/alias、ambiguousのエラーで止まります。 - INNER JOIN では相手のいない行が外れる。最上位の行も残すなら LEFT JOIN です(6行と7行の差)。
- 前行比較は目的で選ぶ。暦の前日なら自己結合、並び順で1つ前なら LAG が合います。
- 重複や組み合わせには
a.id < b.id。自分自身と、左右が入れ替わったペアを除けます(14行 → 8行 → 4行)。 - 段数が決まっていない階層は再帰CTE。繰り返しの部分は自己結合と同じ形です。
- ON の向きは声に出して確かめる。「社員側の manager_id = 上司側の id」と読めれば合っています。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL 自己結合とは|同じテーブルをJOINする書き方と上司部下・前行比較」
株式会社RJCでは、SQLの結合の書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。