NOT EXISTS サブクエリ — NULLに安全な「存在しない行」の取得
WHERE NOT EXISTS (サブクエリ) は、サブクエリが「1行も結果を返さない場合」にその行を残します。NOT IN の NULL の罠がなく、実務で差集合を取る場合の推奨パターンです。
SELECT * FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- 一致する行が0件なら通過 );
users テーブルから、status が '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 | pending |
| 104 | 3 | completed |
| 105 | 3 | completed |
| 106 | 4 | cancelled |
| user_id | name | plan |
|---|---|---|
| 2 | 佐藤 花子 | free |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
SELECT user_id, name, plan FROM users u WHERE NOT EXISTS ( -- SQが0行を返す(行が存在しない)場合に通過 SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- 外側列を参照する相関SQ AND o.status = 'completed' ) ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 1行ずつ処理 2. NOT EXISTS(...) → ユーザーごとにサブクエリを実行 3. SELECT user_id, name, plan → 通過行を選択 4. ORDER BY user_id → user_id 昇順 */
LEGEND
① FROM users
FROM users uusersテーブル全5行を1行ずつ処理します。各行に対して NOT EXISTS サブクエリを評価します。| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
LEFT JOIN orders o ON u.user_id=o.user_id AND o.status='completed' WHERE o.user_id IS NULL でも同じ結果になります。ORMが生成するクエリでよく見かけるパターンです。WHERE user_id NOT IN (SELECT user_id FROM orders) は全件0件を返します。NOT EXISTS に変えるだけで正しく動きます。WHERE o.user_id = u.user_id という外側クエリとの結合条件を書かないと、orders に1件でも行があれば全ユーザーが通過/除外されます。相関条件は必須です。NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.user_id AND status='completed' AND ordered_at >= NOW() - INTERVAL '30 days') のように期間条件をサブクエリ内に入れるだけで実現できます。NOT EXISTS は条件の柔軟性が高く、実務バッチクエリの定番パターンです。相関サブクエリ — 外側クエリの値を内側で参照してユーザー別平均と比較する
相関サブクエリは、外側クエリの現在処理中の行の値を内側のサブクエリが参照する形式です。外側クエリが1行処理されるたびに内側が実行されます。「各ユーザーの平均と個々の注文を比較する」のように、グループごとの集計値と各行を対比するパターンで使います。
SELECT o.order_id, o.amount FROM orders o WHERE o.amount > ( SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id -- 外側 o.user_id を参照(相関条件) );
orders テーブルから、各ユーザーの平均注文額を上回る注文のみを取得してください。取得列は order_id, user_id, amount, user_avg(そのユーザーの平均を付与)とし、user_id 昇順、同一ユーザー内は amount 降順で並べてください。
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
| order_id | user_id | amount | user_avg |
|---|---|---|---|
| 102 | 1 | 12000 | 10000 |
| 104 | 3 | 9500 | 7750 |
SELECT o.order_id, o.user_id, o.amount, (SELECT ROUND(AVG(o2.amount)) -- 相関SQ: そのユーザーの平均を返す FROM orders o2 WHERE o2.user_id = o.user_id -- 外側 o.user_id を参照(相関条件) ) AS user_avg FROM orders o WHERE o.amount > ( -- 各ユーザーの平均と比較して絞り込む SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id -- WHERE でも同じ相関SQを使用 ) ORDER BY o.user_id, o.amount DESC; /* 実行順序(SQLの論理的な評価順): 1. FROM orders o → 全行を1行ずつ処理 2. WHERE o.amount > (相関SQ) → 各行に対しサブクエリを実行 3. SELECT ... user_avg → 相関SQで各ユーザー平均を付与 4. ORDER BY user_id, amount DESC → 並び替え */
LEGEND
① FROM orders
FROM orders oordersテーブル全6行を1行ずつ処理します。各行に対して相関サブクエリが実行されます。| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
FROM orders o(外側)と FROM orders o2(内側)のように同じテーブルを別名で使うことで、相関条件 o2.user_id = o.user_id が意味を持ちます。エイリアスを忘れると意図しない自己参照になります。AVG(amount) OVER (PARTITION BY user_id) で同じ結果を得られ、パフォーマンス上も有利です。相関SQ は行ごとに内側を実行するためデータが多いと遅くなる場合があります。WHERE o2.user_id = o.user_id を省略すると、全注文の平均(6833)との比較になり、相関サブクエリではなく通常のスカラーSQと同じ動作になります。相関条件は必ず確認しましょう。AVG(amount) OVER (PARTITION BY user_id) というウィンドウ関数を使うと1回のテーブルスキャンで済み、パフォーマンスが大幅に改善します。ただし、ウィンドウ関数を WHERE で直接使えないため SELECT 後に派生テーブルや CTE で包む必要があります。FROM句サブクエリ + JOIN — カテゴリ別最高価格商品を1クエリで取得する
「各カテゴリの最高価格商品を取得する」には、まずカテゴリごとの MAX 価格を集計し、その結果テーブルと元テーブルを JOIN するパターンが有効です。相関サブクエリで書くこともできますが、派生テーブル + JOIN の方が大量データで効率的です。
SELECT p.* FROM products p INNER JOIN ( SELECT category, MAX(price) AS max_price -- 派生テーブルでMAXを集計 FROM products GROUP BY category ) AS cat_max ON p.category = cat_max.category AND p.price = cat_max.max_price; -- MAX値と一致する行のみ結合
products テーブルから、カテゴリごとの最高価格の商品を取得してください。取得列は product_id, name, category, price とし、price 降順で並べてください。
| product_id | name | category | price |
|---|---|---|---|
| 1 | プランA | service | 9800 |
| 2 | プランB | service | 4900 |
| 3 | テンプレートX | content | 5500 |
| 4 | テンプレートY | content | 3800 |
| 5 | APIアドオン | option | 3500 |
| 6 | サポート拡張 | option | 2200 |
| product_id | name | category | price |
|---|---|---|---|
| 1 | プランA | service | 9800 |
| 3 | テンプレートX | content | 5500 |
| 5 | APIアドオン | option | 3500 |
SELECT p.product_id, p.name, p.category, p.price FROM products p INNER JOIN ( -- JOIN の右辺に派生テーブルを配置 SELECT category, MAX(price) AS max_price -- カテゴリごとの最高価格を集計 FROM products GROUP BY category ) AS cat_max -- 派生テーブルには必ずエイリアスを付ける ON p.category = cat_max.category -- カテゴリが一致 AND p.price = cat_max.max_price -- かつ価格がMAX値と一致 ORDER BY p.price DESC; /* 実行順序(SQLの論理的な評価順): 1. 派生テーブルを評価 2. FROM products p → productsを全件読み込む 3. INNER JOIN ... ON ... → category一致かつprice=MAX値の行のみ結合 4. SELECT p.product_id... → 必要列を選択 5. ORDER BY p.price DESC → price降順に並び替え */
LEGEND
① 派生テーブル(GROUP BY)
SELECT category, MAX(price) FROM products GROUP BY categoryまず派生テーブルの内側クエリが実行。3カテゴリそれぞれの最高価格を集計します。| product_id | name | category | price | グループ |
|---|---|---|---|---|
| 1 | プランA | service | 9800 | serviceグループ |
| 2 | プランB | service | 4900 | serviceグループ |
| 3 | テンプレートX | content | 5500 | contentグループ |
| 4 | テンプレートY | content | 3800 | contentグループ |
| 5 | APIアドオン | option | 3500 | optionグループ |
| 6 | サポート拡張 | option | 2200 | optionグループ |
AND で条件を追加できます。カテゴリ一致 + 価格一致の2条件を満たす行だけが結合結果に残ります。WHERE で書いても同じ結果ですが、JOIN 結合条件として ON に書く方が意図が明確です。WHERE price = MAX(price) と書くと構文エラーになります。集計関数は WHERE 句で直接使えません。派生テーブルを介するかウィンドウ関数を使いましょう。WHERE price = (SELECT MAX(price) FROM products p2 WHERE p2.category = p.category) でも同じ結果になりますが、行ごとに内側を実行するため大量データで遅くなります。派生テーブル + JOIN の方が効率的です。ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) というウィンドウ関数を使い、WHERE rank <= 3 で絞り込む方法が標準です。サブクエリ + JOIN パターンはウィンドウ関数が使えない環境でのフォールバックとして覚えておきましょう。サブクエリ vs CTE(WITH句)— 同じ処理の2つの書き方を理解する
CTE(Common Table Expression)は WITH 名前 AS (サブクエリ) で定義し、後続のクエリから名前で参照できる「一時的な名前付きテーブル」です。FROM句のサブクエリ(派生テーブル)と多くの場合で等価ですが、可読性と再利用性で優ります。
-- ■ サブクエリ版(ネストが深い) SELECT * FROM ( SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ) AS sub WHERE cnt >= 2; -- ■ CTE版(フラットで読みやすい) WITH sub AS ( SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ) SELECT * FROM sub WHERE cnt >= 2;
orders テーブルを使い、status ごとに注文件数(order_cnt)と合計金額(total_amount)を集計し、order_cnt が 2 以上のステータスのみを total_amount 降順で返してください。サブクエリ版とCTE版の両方を解答してください。
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
期待する出力①:
| status | order_cnt | total_amount |
|---|---|---|
| completed | 4 | 35500 |
期待する出力②:
| status | order_cnt | total_amount |
|---|---|---|
| completed | 4 | 35500 |
-- ■ サブクエリ版(派生テーブル) SELECT status, order_cnt, total_amount FROM ( SELECT status, COUNT(*) AS order_cnt, -- 件数を集計 SUM(amount) AS total_amount -- 合計金額を集計 FROM orders GROUP BY status ) AS summary -- 派生テーブルに必ずエイリアスを付ける WHERE order_cnt >= 2 -- 集計後の件数で絞り込む ORDER BY total_amount DESC; -- ■ CTE版(WITH句)— 上と全く同じ結果を返す WITH summary AS ( -- CTEの定義(名前: summary) SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status ) SELECT -- CTE名で参照(普通のテーブルと同様) status, order_cnt, total_amount FROM summary WHERE order_cnt >= 2 ORDER BY total_amount DESC; /* 実行順序(いずれも同じ論理評価順): 1. 集計クエリ(SQまたはCTE)を評価 → status ごとに集計 2. FROM summary → 集計結果を参照 3. WHERE order_cnt のしきい値 → 条件で絞る 4. SELECT ... → 3列を選択 5. ORDER BY total_amount DESC → 合計降順 */
LEGEND
① 内側クエリ(GROUP BY)
SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY statusまず派生テーブルの内側クエリが実行。ordersをstatus別にグループ化して件数・合計を集計します。| order_id | status | amount | グループ |
|---|---|---|---|
| 101 | completed | 8000 | completedグループ |
| 102 | completed | 12000 | completedグループ |
| 103 | pending | 3500 | pendingグループ |
| 104 | completed | 9500 | completedグループ |
| 105 | completed | 6000 | completedグループ |
| 106 | cancelled | 2000 | cancelledグループ |
LEGEND
① CTE定義(WITH句)
WITH summary AS (SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status)CTEとして「summary」という名前でクエリを定義します。実行内容はサブクエリ版と全く同じです。ネストがなくフラットに書ける点が特徴です。| order_id | status | amount | グループ |
|---|---|---|---|
| 101 | completed | 8000 | completedグループ |
| 102 | completed | 12000 | completedグループ |
| 103 | pending | 3500 | pendingグループ |
| 104 | completed | 9500 | completedグループ |
| 105 | completed | 6000 | completedグループ |
| 106 | cancelled | 2000 | cancelledグループ |
WITH a AS (...), b AS (...) SELECT ... と複数CTE定義可能)②ネストが3段以上になるとき③デバッグ時に中間結果を段階的に確認したいとき。FROM (FROM (FROM ...)) のようなクエリは CTE で分解するのが実務標準です。WITH active_users AS (...), recent_orders AS (...), summary AS (...) SELECT ... のようにすれば、SQL がドキュメントとしても機能します。「サブクエリが3段を超えたらCTEへ」を目安にしてください。複合サブクエリ — IN/HAVING・スカラーSQ・相関SQを組み合わせて実務クエリを構築する
実務のSQLでは複数種類のサブクエリを組み合わせて使うケースが多くあります。この問題では ①WHERE IN + HAVING(高額ユーザーを特定)、②スカラーSQ(相関)(最新注文日を取得)、③スカラーSQ(相関)(注文件数を付与)を1つのクエリで組み合わせます。
SELECT m.id_col, (SELECT MAX(s.date_col) -- 相関スカラーサブクエリ(1値を返す) FROM sub_table s WHERE s.id_col = m.id_col) AS latest_date, (SELECT COUNT(*) FROM sub_table s WHERE s.id_col = m.id_col) AS cnt FROM main_table m WHERE m.id_col IN ( -- IN + HAVING で対象を絞る SELECT s.id_col FROM sub_table s GROUP BY s.id_col HAVING SUM(s.num_col) >= 1000 );
以下の条件を満たすクエリを作成してください:
① orders テーブルで completed の合計金額が 10,000 以上のユーザーの最新注文1件のみを取得
② 取得列は name(users), order_id, amount, ordered_at, total_orders(そのユーザーの全注文件数)
③ total_orders 降順、同一件数は amount 降順で並べること
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 8000 | completed | 2024-05-01 |
| 102 | 1 | 12000 | completed | 2024-05-20 |
| 103 | 2 | 3500 | pending | 2024-05-10 |
| 104 | 3 | 9500 | completed | 2024-05-15 |
| 105 | 3 | 6000 | completed | 2024-05-25 |
| 106 | 4 | 2000 | cancelled | 2024-05-08 |
| name | order_id | amount | ordered_at | total_orders |
|---|---|---|---|---|
| 田中 太郎 | 102 | 12000 | 2024-05-20 | 2 |
| 鈴木 一郎 | 105 | 6000 | 2024-05-25 | 2 |
SELECT u.name, o.order_id, o.amount, o.ordered_at, (SELECT COUNT(*) -- 相関スカラーSQ: 全注文件数を付与 FROM orders o3 WHERE o3.user_id = u.user_id ) AS total_orders FROM users u INNER JOIN orders o ON u.user_id = o.user_id WHERE u.user_id IN ( -- ① WHERE IN + HAVING: 高額ユーザーを特定 SELECT user_id FROM orders WHERE status = 'completed' GROUP BY user_id HAVING SUM(amount) >= 10000 -- completedの合計が10000以上のuser_idリスト ) AND o.ordered_at = ( -- ② 相関スカラーSQ: 最新注文日と一致する行のみ SELECT MAX(o2.ordered_at) FROM orders o2 WHERE o2.user_id = u.user_id ) ORDER BY total_orders DESC, o.amount DESC; /* 実行順序(SQLの論理的な評価順): 1. IN サブクエリを評価 → HAVING付き集計でリストを生成 2. FROM users INNER JOIN orders → user_id で結合 3. WHERE user_id IN (...) → 該当ユーザーに絞る 4. AND o.ordered_at = (相関スカラーSQ) → 各ユーザーの最新注文に絞る 5. SELECT (相関スカラーSQ) → 全注文件数を付与 6. ORDER BY total_orders DESC, o.amount DESC → 件数→金額降順 */
LEGEND
① IN + HAVING サブクエリ
SELECT user_id FROM orders WHERE status='completed' GROUP BY user_id HAVING SUM(amount)>=10000まずcompleted注文をuser_idでグループ化し、合計が10000以上のuser_idだけをリスト化します。これが外側クエリのユーザー絞り込みに使われます。| user_id | SUM(completed) | HAVING >= 10000? |
|---|---|---|
| 1 | 8000+12000=20000 | ✓ [1,3]に追加 |
| 2 | 0(completedなし) | ✗ 除外 |
| 3 | 9500+6000=15500 | ✓ [1,3]に追加 |
| 4 | 0(completedなし) | ✗ 除外 |
| 5 | (注文なし) | ✗ 除外 |
ordered_at = (SELECT MAX(ordered_at) FROM orders WHERE user_id = u.user_id) は「各ユーザーの最新注文」を取得する定番パターンです。MAX による相関SQ を ON や AND 条件に置くことで、JOINしながら最新行だけを残せます。GROUP BY + HAVING を使うことで、「合計金額が一定以上のユーザーID」のリストを1クエリで動的生成できます。HAVING はGROUP BY後の集計値に対してのみ使える絞り込み条件です。WITH high_value_users AS (...), latest_orders AS (...), summary AS (...) SELECT ... のようにCTEで各ステップを名前付きで分解すると、デバッグや仕様変更時に格段に扱いやすくなります。total_orders)があり、さらにWHERE句にも相関SQ(最新ordered_at)があります。行数が増えると O(N²) 相当の評価が発生することがあります。本番では EXPLAIN で実行計画を確認し、必要に応じてウィンドウ関数やCTEに置き換えましょう。