自己結合(セルフジョイン)とは、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が無い環境 合う 使えない 使えない

迷ったときは、次の順で考えると選びやすくなります。

  1. 比べる相手は「並び順で隣の行」か → そうなら LAG・LEAD。
  2. 親子をたどる段数は決まっているか → 決まっていれば自己結合、決まっていなければ再帰CTE。
  3. 行と行のペアそのものが欲しいか → 自己結合。

自己結合を書くときのチェックリスト

確認すること 理由
両側に役割の分かる別名を付けたか 別名が無いと 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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?