CTE / サブクエリ

WITH 句(CTE)でクエリを段階的に組み立てる、再帰 CTE でツリーを辿る、 サブクエリの種類と使い分け。

サブクエリの分類

スカラーサブクエリ

SELECT
  id, title,
  (SELECT count(*) FROM comments WHERE comments.post_id = posts.id) AS comment_count
FROM posts;

各行ごとにサブクエリ実行。便利だが大量の行で遅くなりがち。Window 関数 / JOIN GROUP BY の方が速いことが多い。

インラインビュー(FROM 句)

SELECT category, avg_views FROM (
  SELECT category, AVG(views) AS avg_views
  FROM posts
  GROUP BY category
) t
WHERE avg_views > 100;

相関サブクエリ

-- 各ユーザの「最新投稿のタイトル」
SELECT
  u.id, u.email,
  (SELECT title FROM posts
   WHERE user_id = u.id
   ORDER BY created_at DESC LIMIT 1) AS latest_title
FROM users u;

EXISTS

SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM posts WHERE user_id = u.id AND status = 'published'
);

IN より1 件見つけた時点で停止するので速いことが多い。

NOT EXISTS で「持っていない」

SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM posts WHERE user_id = u.id);

CTE(Common Table Expressions)

WITH で名前付きの中間結果を定義してから本体クエリで使う。長いクエリを段階的に書ける。

WITH active_users AS (
  SELECT id, email FROM users WHERE is_active
),
their_posts AS (
  SELECT u.email, p.id, p.title
  FROM active_users u
  JOIN posts p ON p.user_id = u.id
)
SELECT * FROM their_posts WHERE title ILIKE '%react%';

CTE の利点

CTE の注意

CTE での DML 連鎖

WITH inserted AS (
  INSERT INTO logs (user_id, event)
  VALUES (1, 'login')
  RETURNING id, user_id
)
UPDATE users SET last_login_log_id = inserted.id
FROM inserted
WHERE users.id = inserted.user_id;

再帰 CTE

ツリー構造(コメント、組織、カテゴリ)を辿る:

WITH RECURSIVE comment_tree AS (
  -- 起点(base case): 親なしのトップレベル
  SELECT id, body, parent_id, 0 AS depth
  FROM comments
  WHERE post_id = 42 AND parent_id IS NULL

  UNION ALL

  -- 再帰: 既に集めたコメントの子
  SELECT c.id, c.body, c.parent_id, ct.depth + 1
  FROM comments c
  JOIN comment_tree ct ON c.parent_id = ct.id
)
SELECT * FROM comment_tree
ORDER BY depth, id;

無限ループ防止

CYCLE 構文

WITH RECURSIVE g(id, parent_id, path) AS (
  SELECT id, parent_id, ARRAY[id] FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.parent_id, path || c.id
  FROM categories c JOIN g ON c.parent_id = g.id
)
CYCLE id SET is_cycle USING cycle_path
SELECT * FROM g;

パスや階層番号を持たせる

WITH RECURSIVE org AS (
  SELECT id, name, parent_id, ARRAY[name] AS path FROM departments WHERE parent_id IS NULL
  UNION ALL
  SELECT d.id, d.name, d.parent_id, org.path || d.name
  FROM departments d JOIN org ON d.parent_id = org.id
)
SELECT id, array_to_string(path, ' > ') AS hierarchy FROM org;

連番生成 / 範囲

-- generate_series(Postgres)
SELECT generate_series(1, 10);
SELECT generate_series('2026-01-01'::date, '2026-12-31'::date, '1 day'::interval);

-- 再帰 CTE で(標準 SQL)
WITH RECURSIVE nums(n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 10
)
SELECT * FROM nums;

欠落日付の補完

-- 集計データに 0 件の日も含める
WITH date_range AS (
  SELECT generate_series(
    date_trunc('day', NOW() - INTERVAL '30 days'),
    date_trunc('day', NOW()),
    '1 day'
  )::date AS day
)
SELECT
  d.day,
  COALESCE(count(p.id), 0) AS posts
FROM date_range d
LEFT JOIN posts p ON date_trunc('day', p.created_at) = d.day
GROUP BY d.day
ORDER BY d.day;

UPSERT 風(INSERT ... ON CONFLICT)と CTE

WITH new_data AS (
  SELECT * FROM staging_users
)
INSERT INTO users (id, email, name)
SELECT id, email, name FROM new_data
ON CONFLICT (id) DO UPDATE
  SET email = EXCLUDED.email,
      name = EXCLUDED.name;

ビュー vs CTE

サブクエリの代替

JOIN への書き換え

-- IN サブクエリ
SELECT * FROM users WHERE id IN (SELECT user_id FROM posts);

-- JOIN(DISTINCT が必要)
SELECT DISTINCT u.* FROM users u JOIN posts p ON p.user_id = u.id;

-- EXISTS(推奨)
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = u.id);

典型的な書き分け

用途選択
「持っている」判定EXISTS
「持っていない」判定NOT EXISTS
関連取得JOIN
段階分解CTE
ツリー / グラフ再帰 CTE
列ごとの個別集計スカラーサブクエリ or LATERAL

失敗パターン

症状対処
サブクエリで遅いEXISTS / JOIN に書き換え
CTE が遅い (古い PG)サブクエリ化、12 以前は注意
再帰 CTE が止まらない循環あり。WHERE depth で抑制
結果が NULL に化けるNOT IN + NULL の罠 → NOT EXISTS へ
長すぎる読めない SQLCTE で段階分け

NOT IN + NULL の罠

-- 危険
SELECT * FROM a WHERE id NOT IN (SELECT id FROM b);
-- b.id に NULL が 1 つでもあると、結果が空になる!

-- 安全
SELECT * FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id);

長いクエリを書く流儀

WITH
  active_users AS (
    SELECT id, email FROM users WHERE is_active
  ),
  recent_posts AS (
    SELECT * FROM posts WHERE created_at >= NOW() - INTERVAL '7 days'
  ),
  user_stats AS (
    SELECT u.id, u.email, count(p.id) AS post_count
    FROM active_users u
    LEFT JOIN recent_posts p ON p.user_id = u.id
    GROUP BY u.id, u.email
  )
SELECT * FROM user_stats WHERE post_count > 0 ORDER BY post_count DESC;

各 CTE は1 つの責務。プログラミングと同じ感覚で関数化する。

習慣化したい

複雑な SQL は CTE で段階的に。再帰 CTE が書けると、ツリー / グラフ系の問題が一気に解ける。 1 度書けるようになると、もう手放せない技術。