相関サブクエリ — 同一テーブルから各ユーザーの「最新注文」のみを抽出する
相関サブクエリ(Correlated Subquery)とは、内側のサブクエリが外側クエリの列を参照するサブクエリです。外側クエリが1行処理されるたびに内側が実行されるため、「その行のユーザーに対する最大値」のように行ごとに異なる値を動的に計算できます。
SELECT * FROM orders o1 WHERE ordered_at = ( SELECT MAX(ordered_at) -- o1 の user_id に対する最大値を計算 FROM orders o2 -- 同一テーブルを別エイリアスで参照 WHERE o2.user_id = o1.user_id -- 外側の列を参照(これが「相関」) );
orders テーブルから、各ユーザーの最新注文(ordered_at が最大の行)のみを取得してください。order_id, user_id, amount, status, ordered_at を user_id 昇順で返してください。
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 8,000 | completed | 2024-05-01 |
| 102 | 1 | 12,000 | completed | 2024-05-20 |
| 103 | 2 | 3,500 | completed | 2024-05-10 |
| 104 | 3 | 9,500 | completed | 2024-05-15 |
| 105 | 3 | 11,000 | completed | 2024-05-25 |
| 106 | 4 | 2,000 | completed | 2024-05-08 |
| 107 | 1 | 15,000 | completed | 2024-06-10 |
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 107 | 1 | 15,000 | completed | 2024-06-10 |
| 103 | 2 | 3,500 | completed | 2024-05-10 |
| 105 | 3 | 11,000 | completed | 2024-05-25 |
| 106 | 4 | 2,000 | completed | 2024-05-08 |
SELECT order_id, user_id, amount, status, ordered_at FROM orders o1 -- 外側クエリに o1 というエイリアス WHERE ordered_at = ( -- 各行の ordered_at を SQ 結果と比較 SELECT MAX(ordered_at) -- そのユーザーの最大日付を求める FROM orders o2 -- 同一テーブルを別名 o2 で参照(自己参照) WHERE o2.user_id = o1.user_id -- 外側の列を参照するのが「相関」の本質 ) ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM orders o1 → 全7行を1行ずつ処理 2. 相関SQ → 各行で MAX(ordered_at) と比較 3. WHERE フィルタ → 各ユーザーの最新注文に絞る 4. SELECT ... → 必要列を選択 5. ORDER BY user_id → user_id 昇順 */
LEGEND
① FROM orders o1(外側クエリ)
FROM orders o1外側クエリのordersテーブル(o1)全7行を1行ずつ処理します。ここから1行ごとに相関サブクエリが評価されます。| o1.order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 101 | 1 | 8,000 | 2024-05-01 |
| 102 | 1 | 12,000 | 2024-05-20 |
| 103 | 2 | 3,500 | 2024-05-10 |
| 104 | 3 | 9,500 | 2024-05-15 |
| 105 | 3 | 11,000 | 2024-05-25 |
| 106 | 4 | 2,000 | 2024-05-08 |
| 107 | 1 | 15,000 | 2024-06-10 |
o2.user_id = o1.user_id と書くことで、外側クエリが処理中の行の user_id に応じた MAX を計算します。外側行が変わるたびに内側が再実行されるため、行ごとに異なる集計値が得られます。非相関SQは1回だけ評価される点が根本的に異なります。orders o1(外側)と orders o2(内側)は同じテーブルですが別のエイリアスを付けることで区別します。エイリアスがないと「どちらの列か」が曖昧になりエラーになります。orders.user_id にインデックスがない場合は全件スキャンが外側行数分発生します。大量データでは ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) のウィンドウ関数+CTEが推奨されます。ORDER BY ordered_at DESC, order_id DESC LIMIT 1 を使う派生テーブルか、ウィンドウ関数+CTEで対処します。FROM orders o WHERE ... = (SELECT MAX(...) FROM orders o WHERE ...) と書くと内側の o が外側を隠してしまいます。内側・外側に必ず別エイリアス(o1/o2 など)を付けることが必須です。ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) を使ったCTEが実務の第一選択です。これはインデックスを効率的に使え、同日付のタイブレークも order_id で制御できます。相関SQは「どのRDBMSでも動くポータブルな書き方」として理解しておくと古い環境や試験問題でも対応できます。HAVING × スカラーサブクエリ — グループ集計値を全体平均と比較して絞り込む
HAVING 句は GROUP BY で集計した結果に対してフィルタをかけます。WHERE 句は集計前(行単位)、HAVING 句は集計後(グループ単位)のフィルタです。この HAVING 句の比較値にスカラーSQ を使うことで、全体集計値とグループ集計値を動的に比較できます。
SELECT col, AVG(amount) FROM orders GROUP BY col HAVING AVG(amount) > ( -- 集計後のグループ条件 SELECT AVG(amount) FROM orders -- スカラーSQ: 全体平均を返す );
WHERE AVG(amount) > ... は構文エラーになります。WHERE は GROUP BY より先に評価されるため集計関数を条件に使えません。集計後に絞り込むには必ず HAVING を使います。orders テーブルから、status が 'completed' の注文に限り、ユーザーごとの平均注文額が全体平均より高いユーザーを取得してください。user_id, avg_amount(ROUND後の整数値) を avg_amount 降順で返してください。
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8,000 | completed |
| 102 | 1 | 12,000 | completed |
| 103 | 2 | 3,500 | completed |
| 104 | 3 | 9,500 | completed |
| 105 | 3 | 11,000 | completed |
| 106 | 4 | 2,000 | completed |
| 107 | 1 | 15,000 | completed |
| user_id | avg_amount |
|---|---|
| 1 | 11,667 |
| 3 | 10,250 |
SELECT user_id, ROUND(AVG(amount)) AS avg_amount -- 表示用にROUNDで整数化 FROM orders WHERE status = 'completed' -- 集計前フィルタ(行単位) GROUP BY user_id -- ユーザーごとにグループ化 HAVING AVG(amount) > ( -- 集計後フィルタ(グループ単位) SELECT AVG(amount) -- スカラーSQ: completed全体の平均を先行評価 FROM orders WHERE status = 'completed' -- 外側と同じ条件で揃える ) ORDER BY avg_amount DESC; /* 実行順序(SQLの論理的な評価順): 1. スカラーSQ先行評価 → completed の平均を閾値として確定 2. FROM orders → 全行読み込み 3. WHERE status='completed' → completed に絞る 4. GROUP BY user_id → ユーザーで集計 5. HAVING AVG(amount) > 閾値 → 平均超のグループを残す 6. SELECT ROUND(AVG(amount)) → 平均を出力 7. ORDER BY avg_amount DESC → 降順 */
LEGEND
① スカラーSQ(全体平均の確定)
SELECT AVG(amount) FROM orders WHERE status='completed'HAVING句の比較閾値となる全体平均をサブクエリが先行して1回だけ計算します。7件全体の平均値(約8,714)が定数として確定します。| order_id | user_id | amount | AVG計算対象 |
|---|---|---|---|
| 101 | 1 | 8,000 | ✓ 対象 |
| 102 | 1 | 12,000 | ✓ 対象 |
| 103 | 2 | 3,500 | ✓ 対象 |
| 104 | 3 | 9,500 | ✓ 対象 |
| 105 | 3 | 11,000 | ✓ 対象 |
| 106 | 4 | 2,000 | ✓ 対象 |
| 107 | 1 | 15,000 | ✓ 対象 |
WHERE AVG(amount) > ... は GROUP BY より前に評価されるため集計関数を使えず構文エラーになります。集計値を条件にするには必ず HAVING を使います。WHERE status='completed' で揃えています。内側を WHERE なしにすると「全ステータス込みの全体平均」になり意味が変わります。「何の平均と比べるのか」を明確に設計することが重要です。WHERE AVG(amount) > 8714 は構文エラーです。集計関数は WHERE 句では使用できません。集計後の条件には必ず HAVING を使いましょう。初学者が最もハマるポイントの一つです。WHERE なし(全ステータス平均)だと「全体平均」の意味が変わります。内外の絞り込み条件を意図的に揃えるか変えるかを明確に設計しましょう。EXISTS + NOT EXISTS — 「注文済み・未レビュー」ユーザーを複合条件で抽出する
WHERE EXISTS (...) AND NOT EXISTS (...) のように複数の EXISTS / NOT EXISTS を AND で組み合わせることで、「条件Aを満たし、かつ条件Bを満たさない行」を1クエリで抽出できます。基礎編の EXISTS を複合化した実務パターンです。
SELECT * FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- 注文が存在する ) AND NOT EXISTS ( SELECT 1 FROM reviews r WHERE r.user_id = u.user_id -- かつレビューが存在しない );
users テーブルから、completed の注文が1件以上あるにもかかわらず、まだ一度もレビューを投稿していないユーザーを取得してください。user_id, name, plan を user_id 昇順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | completed |
| 104 | 3 | completed |
| 105 | 3 | completed |
| 106 | 4 | completed |
| 107 | 1 | completed |
| review_id | user_id | order_id | rating |
|---|---|---|---|
| 1 | 1 | 101 | 5 |
| 2 | 3 | 104 | 4 |
| user_id | name | plan |
|---|---|---|
| 2 | 佐藤 花子 | free |
| 4 | 山田 次郎 | standard |
SELECT u.user_id, u.name, u.plan FROM users u WHERE EXISTS ( -- 条件①: completedの注文が1件以上ある SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'completed' -- completedのみ対象 ) AND NOT EXISTS ( -- 条件②: レビューが1件もない SELECT 1 FROM reviews r WHERE r.user_id = u.user_id -- reviewsに行が存在しないことを確認 ) ORDER BY u.user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 1行ずつ処理 2. EXISTS(completed注文) → completed ありを判定 3. AND NOT EXISTS(レビュー不在) → レビューなしを判定 4. SELECT u.user_id, u.name, u.plan → 通過行を選択 5. ORDER BY u.user_id → user_id 昇順 */
LEGEND
① FROM users u(全行読み込み)
FROM users uusersテーブル全5行を1行ずつ処理します。各行に対してEXISTSとNOT EXISTSの2つのサブクエリが順番に評価されます。| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
user_id に NULL が混入しても NOT EXISTS は期待通り動作します。一方 NOT IN (SELECT user_id FROM reviews) は NULL が1件でも存在すると全行 UNKNOWN になり0件が返る危険があります(基礎編 Q3参照)。実務では NOT EXISTS を第一選択にしましょう。WHERE user_id NOT IN (SELECT user_id FROM reviews) は reviews.user_id に NULL が入った瞬間に全行除外されます。reviews は運用中に NULL が混入しうるため必ず NOT EXISTS を使うか、内側に WHERE user_id IS NOT NULL を追加してください。LEFT JOIN reviews ON ... WHERE reviews.user_id IS NULL でも得られますが、EXISTS/NOT EXISTS は「存在確認のみ」で選択列を増やさない点が明確です。結合先の列も SELECT に出したい場合は JOIN を使い、存在確認だけなら EXISTS が意図を明確に表します。派生テーブル × INNER JOIN — 集計サマリにユーザー情報を結合して一覧を返す
基礎編では FROM 句サブクエリ(派生テーブル)でグループ集計し外側 WHERE でフィルタしました。このパターンをさらに発展させ、集計結果(派生テーブル)を別のマスタテーブルと INNER JOINすることで、集計値と関連情報を1クエリで取得するのが実務で最頻出のパターンです。
SELECT m.name, s.total FROM master m INNER JOIN ( SELECT id, SUM(amount) AS total FROM transactions GROUP BY id ) AS s ON m.id = s.id; -- 集計結果とマスタを結合
orders テーブルを集計してユーザーごとの注文件数・合計金額・平均金額を求め、users テーブルの name / plan と組み合わせて返してください。completed の注文のみ対象とし、user_id, name, plan, order_count, total_amount, avg_amount を total_amount 降順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8,000 | completed |
| 102 | 1 | 12,000 | completed |
| 103 | 2 | 3,500 | completed |
| 104 | 3 | 9,500 | completed |
| 105 | 3 | 11,000 | completed |
| 106 | 4 | 2,000 | completed |
| 107 | 1 | 15,000 | completed |
| user_id | name | plan | order_count | total_amount | avg_amount |
|---|---|---|---|---|---|
| 1 | 田中 太郎 | premium | 3 | 35,000 | 11,667 |
| 3 | 鈴木 一郎 | premium | 2 | 20,500 | 10,250 |
| 2 | 佐藤 花子 | free | 1 | 3,500 | 3,500 |
| 4 | 山田 次郎 | standard | 1 | 2,000 | 2,000 |
SELECT u.user_id, u.name, u.plan, s.order_count, s.total_amount, s.avg_amount FROM users u INNER JOIN ( -- 集計結果を派生テーブルとして INNER JOIN SELECT user_id, COUNT(*) AS order_count, -- 注文件数 SUM(amount) AS total_amount,-- 合計金額 ROUND(AVG(amount)) AS avg_amount -- 平均金額(整数化) FROM orders WHERE status = 'completed' -- 集計前に completed のみに絞る GROUP BY user_id ) AS s ON u.user_id = s.user_id -- AS エイリアス必須。ON で結合キー指定 ORDER BY s.total_amount DESC; /* 実行順序(SQLの論理的な評価順): 1. 派生テーブル(s)を評価 2. FROM users u → users 全5行を読み込む 3. INNER JOIN s ON user_id → user5はsに行がないため結合から自動除外 4. SELECT u.*, s.* → 必要列を選択 5. ORDER BY s.total_amount DESC → 合計金額降順 */
LEGEND
① 派生テーブル(s)の生成
SELECT user_id, COUNT(*), SUM(amount), ROUND(AVG(amount)) FROM orders WHERE status='completed' GROUP BY user_id内側のサブクエリが先に評価され、ユーザーごとの集計結果を持つ「仮想テーブル(s)」がメモリ上に生成されます。この時点で注文のないuser5は含まれません。| user_id | order_count | total_amount | avg_amount |
|---|---|---|---|
| 1 | 3 | 35,000 | 11,667 |
| 2 | 1 | 3,500 | 3,500 |
| 3 | 2 | 20,500 | 10,250 |
| 4 | 1 | 2,000 | 2,000 |
AS s のようなエイリアスを付けてください。エイリアスなしは PostgreSQL・MySQL どちらもエラーになります。外側クエリでは s.order_count のようにエイリアス経由で列を参照します。AS s を付けないと構文エラーになります(PostgreSQL・MySQL ともに必須)。外側から参照する列名もエイリアス経由でないと曖昧さエラーになる場合があります。WITH order_summary AS (SELECT user_id, COUNT(*) AS order_count, ... FROM orders WHERE status='completed' GROUP BY user_id) SELECT u.*, s.* FROM users u INNER JOIN order_summary s ON u.user_id = s.user_id ORDER BY s.total_amount DESC; とすることで集計ロジックを本体から切り出せます。複数の派生テーブルが必要な複雑なクエリはCTEが特に有効で、デバッグやレビューが格段に楽になります。多段ネストSQ — 「最も売れたカテゴリ」の商品一覧をサブクエリの入れ子で取得する
サブクエリはさらに別のサブクエリを内包することができます(多段ネスト)。外側から順に「カテゴリ最高売上を取り出す → そのカテゴリ名を特定する → そのカテゴリの商品を取り出す」のように、段階的に絞り込みを行うことで複雑な条件を表現できます。
SELECT * FROM products WHERE category = ( SELECT category -- ② カテゴリ名を求める FROM sales_summary WHERE total = ( SELECT MAX(total) -- ① 最大値を先に確定 FROM sales_summary ) );
以下の3テーブルを使い、完了注文(completed)における販売数量(qty)の合計が最も多いカテゴリの商品一覧を取得してください。product_id, name, category, price を price 降順で返してください。
| product_id | name | category | price |
|---|---|---|---|
| 1 | ワイヤレスイヤホン | electronics | 8,000 |
| 2 | スマートウォッチ | electronics | 25,000 |
| 3 | コットンTシャツ | apparel | 3,500 |
| 4 | デニムジャケット | apparel | 12,000 |
| 5 | プロテインパウダー | health | 5,000 |
| order_id | status |
|---|---|
| 101 | completed |
| 102 | completed |
| 103 | completed |
| 104 | pending |
| item_id | order_id | product_id | qty |
|---|---|---|---|
| 1 | 101 | 1 | 2 |
| 2 | 101 | 3 | 1 |
| 3 | 102 | 2 | 1 |
| 4 | 102 | 1 | 3 |
| 5 | 103 | 4 | 2 |
| 6 | 103 | 5 | 1 |
| 7 | 104 | 2 | 2 |
| product_id | name | category | price |
|---|---|---|---|
| 2 | スマートウォッチ | electronics | 25,000 |
| 1 | ワイヤレスイヤホン | electronics | 8,000 |
SELECT product_id, name, category, price FROM products WHERE category = ( -- 最多売上カテゴリ名と一致する商品を取り出す SELECT p2.category -- ② カテゴリ名を取得(スカラーSQ) FROM products p2 INNER JOIN order_items oi ON p2.product_id = oi.product_id INNER JOIN orders o ON oi.order_id = o.order_id WHERE o.status = 'completed' -- completed 注文のみ対象 GROUP BY p2.category HAVING SUM(oi.qty) = ( -- カテゴリ別合計qty が最大値と一致 SELECT MAX(cat_qty) -- ① 最大の合計qty を先に確定(最内側SQ) FROM ( SELECT SUM(oi2.qty) AS cat_qty -- カテゴリ別の合計qty FROM products p3 INNER JOIN order_items oi2 ON p3.product_id = oi2.product_id INNER JOIN orders o2 ON oi2.order_id = o2.order_id WHERE o2.status = 'completed' GROUP BY p3.category ) AS cat_totals -- 派生テーブルに必ずエイリアスを付ける ) ) ORDER BY price DESC; /* 実行順序(SQLの論理的な評価順): 1. 最内側SQ(派生テーブル cat_totals)を評価 2. 2段目SQ: MAX(cat_qty) = 6(最大値を確定) 3. 中間SQ: HAVING SUM(oi.qty) = 6 のカテゴリを取得 4. 外側WHERE: products.category = 'electronics' で絞り込む 5. SELECT ... → 必要列を選択 6. ORDER BY price DESC → 価格降順 */
LEGEND
① 最内側SQ(データ取得・結合)
FROM products p3 INNER JOIN order_items oi2 ... WHERE o2.status='completed'最も深いサブクエリから評価が始まります。まず、完了(completed)した注文の明細(order_items)と商品情報(products)を結合し、集計のベースとなるデータを用意します。| o2.order_id | category | qty | status |
|---|---|---|---|
| 101 | electronics | 2 | completed |
| 101 | apparel | 1 | completed |
| 102 | electronics | 1 | completed |
| 102 | electronics | 3 | completed |
| 103 | apparel | 2 | completed |
| 103 | health | 1 | completed |
| 104 | electronics | 2 | pending(除外) |
SELECT MAX(cat_qty) FROM (...) が最初に評価され定数値(6)を返します。次に中間SQが HAVING SUM(oi.qty) = 6 でカテゴリ名を取得し、最後に外側クエリがそのカテゴリの商品を取り出します。設計時は「何を内側で確定させれば外側がシンプルになるか」を内側から逆算するのがコツです。WITH cat_totals AS (...) SELECT MAX(cat_qty) FROM cat_totals のように整理しましょう。LIMIT 1 や ORDER BY ... LIMIT 1 でタイブレークを明示するか、INに変更して複数対応させる設計が必要です。WITH cat_totals AS (...カテゴリ別qty集計...) ② top_category AS (SELECT category FROM cat_totals WHERE cat_qty = (SELECT MAX(cat_qty) FROM cat_totals)) ③ SELECT * FROM products WHERE category IN (SELECT category FROM top_category) というように段階的に分解することで、各ステップのデバッグも容易になります。