相関サブクエリと N+1 — SELECT句のサブクエリは「行数ぶん」実行される
SELECT句に書いた相関サブクエリ(外側の行の値を参照するサブクエリ)は、外側の結果1行ごとに1回実行されます。外側が10万行でサブクエリが2本なら、内側テーブルへの探索が20万回発生する——これがSQL版の N+1問題です。1回1回が index で速くても、「回数 × 1回のコスト」の掛け算からは逃げられません。
-- ✗ users の行数ぶん orders への探索が走る(しかも2本) SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id), (SELECT SUM(amount) FROM orders o WHERE o.user_id = u.user_id) FROM users u; -- ✓ JOIN + GROUP BY:各テーブルの走査は1回ずつ、集約は1パス SELECT u.name, COUNT(o.order_id), COALESCE(SUM(o.amount), 0) FROM users u LEFT JOIN orders o ON o.user_id = u.user_id GROUP BY u.user_id, u.name;
EXPLAIN ANALYZE で SubPlan と loops=N(Nが外側の行数)が出ていたら N+1 です。クエリの形は「1文」でも、実行の中身はループになっています。users(実体10万行)と orders(実体500万行)から、ユーザーごとの注文件数と合計金額を取得してください。ただし orders への走査がユーザー数に比例しない(テーブル走査が各1回で済む)形で書くこと。注文ゼロのユーザーも 件数0・合計0 で出力します。出力列は user_id, name, order_cnt, total_amount(user_id 昇順)。
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 2 | 3000 |
| 104 | 3 | 500 |
| 105 | 3 | 700 |
| 106 | 3 | 300 |
| user_id | name | order_cnt | total_amount |
|---|---|---|---|
| 1 | Sato | 2 | 2000 |
| 2 | Suzuki | 1 | 3000 |
| 3 | Tanaka | 3 | 1500 |
| 4 | Ito | 0 | 0 |
SELECT u.user_id, u.name, COUNT(o.order_id) AS order_cnt, -- NULL は数えない → 注文ゼロは 0 COALESCE(SUM(o.amount), 0) AS total_amount -- 全行NULLのSUMはNULL → 0 に変換 FROM users u LEFT JOIN orders o ON o.user_id = u.user_id GROUP BY u.user_id, u.name ORDER BY u.user_id; /* 実行順序: 1. FROM/LEFT JOIN → users と orders を結合(未注文も保持) 2. GROUP BY → user_id, name でグループ化 3. 集約 → COUNT / SUM を1パスで計算 4. SELECT → COALESCE で SUM の NULL を0補正 5. ORDER BY → user_id 昇順に整列 */
LEGEND
1. 対象テーブル — users(外側になる側)
FROM users u求めたいのは「ユーザー1人につき1行」の集計結果です。注目すべきは外側になる users の行数。相関サブクエリの実行回数は、この行数に正比例します。| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
EXPLAIN ANALYZE の SubPlan 配下にある loops=100000 がその証拠です。LATERAL が適任です。重要なのは形の善悪の暗記ではなく、EXPLAIN で実際の実行回数を確認する習慣です。SELECT SUM(...) を発行するのは、本問のNG形をネットワーク往復付きでさらに遅くしたものです。SQL内のN+1とアプリのN+1は同根——「まとめて1回」の原則はレイヤーを問わず効きます。NULL と三値論理 — 「等しくない」比較は NULL を黙って落とす
SQLの比較結果は TRUE / FALSE の二値ではなく、TRUE / FALSE / UNKNOWN の三値です。NULL が絡む比較は = でも <> でも結果が UNKNOWN になり、WHERE 句は TRUE の行しか通しません。つまり status <> 'cancelled' と書くと、status が NULL の行はエラーも警告もなく結果から消えます。
-- ✗ NULL <> 'cancelled' は UNKNOWN → WHERE を通過できず黙って消える WHERE status <> 'cancelled' -- ✓ NULL を「1つの値」として比較する → 結果は必ず TRUE/FALSE WHERE status IS DISTINCT FROM 'cancelled' -- ✓ 互換性重視の伝統的な書き方(意味は同じ) WHERE (status <> 'cancelled' OR status IS NULL)
orders テーブルの status 列は NULL 許可です(旧システムから移行した注文は status 未設定 = NULL)。「キャンセル以外の注文」をすべて取得してください。status が NULL の注文も「キャンセルではない」として含めること。出力列は order_id, status, amount(order_id 昇順)。
| order_id | status | amount |
|---|---|---|
| 1 | paid | 1200 |
| 2 | cancelled | 3000 |
| 3 | NULL | 800 |
| 4 | shipped | 500 |
| 5 | cancelled | 700 |
| 6 | NULL | 2000 |
| order_id | status | amount |
|---|---|---|
| 1 | paid | 1200 |
| 3 | NULL | 800 |
| 4 | shipped | 500 |
| 6 | NULL | 2000 |
SELECT order_id, status, amount FROM orders WHERE status IS DISTINCT FROM 'cancelled' -- NULL も「cancelled と異なる」と判定される ORDER BY order_id; -- 別解(古いDBMSでも動く伝統形): WHERE (status <> 'cancelled' OR status IS NULL) /* 実行順序: 1. FROM: orders を走査 2. WHERE: 各行で status IS DISTINCT FROM 'cancelled' を評価 → NULL を「値」として扱う二値比較なので、結果は必ず TRUE / FALSE(UNKNOWN なし) → NULL 行も TRUE になり通過する 3. SELECT: 3列を確定 4. ORDER BY: order_id 昇順に整列 → 結果: 4行 × 3列(取りこぼしゼロ) */
LEGEND
1. 対象テーブル — status に NULL が混ざっている
FROM ordersid=3, 6 の status は NULL(未設定)。実務では「旧システム移行分」「項目追加前の既存行」など、NULL 許可列に NULL が実在するのはごく普通の状態です。この前提を忘れた瞬間に事故が起きます。| order_id | status | amount |
|---|---|---|
| 1 | paid | 1200 |
| 2 | cancelled | 3000 |
| 3 | NULL | 800 |
| 4 | shipped | 500 |
| 5 | cancelled | 700 |
| 6 | NULL | 2000 |
= 'paid')では NULL 行が落ちても直感に合いますが、否定・除外条件(<> / NOT LIKE / NOT IN)では「除外したい値以外まで消える」ため事故になります。既習の「NOT IN と NULL の罠」はサブクエリ側の NULL でしたが、本問は列側の NULL——同じ三値論理の別の顔です。a IS DISTINCT FROM b は NULL を値として扱い、必ず TRUE/FALSE を返します(NULL IS DISTINCT FROM 'x' は TRUE、NULL IS NOT DISTINCT FROM NULL も TRUE)。意図が1句で読み取れるのが利点。一方、対応していないDBMSや古いバージョンでは (a <> b OR a IS NULL) の伝統形を使います。チームの方言に合わせつつ、「NULL をどちらに含めるか」を必ず明示するのが本質です。<> 'cancelled' のような除外条件は通常ほぼ全行が該当するため(選択率が高い)、index を使うより Seq Scan が合理的とプランナは判断します。つまり本問の主戦場は速度ではなく正しさ。逆に「cancelled だけ抽出」のような少数派の抽出は index が最も輝く場面で、部分インデックスに直結します。COALESCE(status, '') <> 'cancelled' は一見正しく動きますが、列を関数で包む Sargable 違反(前作同じ構図)であり、「空文字と NULL を同一視する」という暗黙仕様をクエリ側に焼き込んでしまいます。比較の意味は IS DISTINCT FROM や IS NULL で明示するのが筋です。NOT NULL 制約 + DEFAULT をスキーマに付けるのが本筋です。NULL を許すなら「未設定?不明?対象外?」というNULL の業務的な意味を仕様として文書化しないと、開発者ごとに解釈が割れて集計値がブレ続けます。COUNT(col) は NULL を数えない・AVG の分母から外れる)、CHECK 制約(UNKNOWN は通ってしまう)、そして既習の NOT IN。新しいテーブルを触るときは最初に \d テーブル名 で NOT NULL 制約の有無を確認する習慣をつけると、この種の事故を設計段階で潰せます。次の Q3 では、正しさと速さの両方が絡むもう1つの頻出地帯——日付・時刻の範囲条件を扱います。日付条件の Sargable 化 — 関数で切らず「半開区間」で絞る
「2026年5月の注文」を DATE_TRUNC や EXTRACT で書くと、列が関数に包まれて index が使えません(全行で関数を実行してから比較)。期間は本来、時間軸上の連続した区間なので、半開区間 >= 開始 AND < 終了に書き換えれば B-tree の Range Scan がそのまま効きます。さらに timestamp 列への BETWEEN は末日のデータを取りこぼす正しさの罠も抱えています。
-- ✗ 列を関数で包む → 全行評価(index 不使用) WHERE DATE_TRUNC('month', ordered_at) = DATE '2026-05-01' -- ✗ BETWEEN の上限は '2026-05-31 00:00:00' → 5/31 の日中データが消える WHERE ordered_at BETWEEN '2026-05-01' AND '2026-05-31' -- ✓ 半開区間 [5/1, 6/1):index が効き、取りこぼしもない WHERE ordered_at >= DATE '2026-05-01' AND ordered_at < DATE '2026-06-01'
orders テーブル(実体800万行)の ordered_at 列は timestamp 型で、インデックス idx_orders_ordered_at があります。2026年5月分の注文件数と合計金額を、Index Range Scan が効き、かつ月末のデータを取りこぼさない形で集計してください。出力列は order_cnt, total_amount。
| order_id | ordered_at | amount |
|---|---|---|
| 101 | 2026-04-30 23:50 | 900 |
| 102 | 2026-05-01 00:00 | 1200 |
| 103 | 2026-05-14 12:30 | 800 |
| 104 | 2026-05-31 18:45 | 3000 |
| 105 | 2026-06-01 00:10 | 1500 |
| 106 | 2026-05-08 09:15 | 500 |
| order_cnt | total_amount |
|---|---|
| 4 | 5500 |
SELECT COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE ordered_at >= DATE '2026-05-01' -- 開始は含む AND ordered_at < DATE '2026-06-01'; -- 終了は含まない(半開区間) /* 実行順序: 1. WHERE の範囲条件 → idx_orders_ordered_at を採用 2. B-tree を二分探索 → 区間の先頭へジャンプ 3. 時刻順に連続走査 → 区間の終端で終了 4. 区間内のみ COUNT / SUM → 1パスで集計 */
LEGEND
1. 対象テーブル — index は時刻順に整列している
FROM orders(idx_orders_ordered_at)idx_orders_ordered_at の中では行が時刻順の連続した並びになっています。「2026年5月」という期間は、この並びの上ではひとつながりの区間——ここが半開区間書き換えの物理的な根拠です。| order_id | ordered_at | amount |
|---|---|---|
| 101 | 2026-04-30 23:50 | 900 |
| 102 | 2026-05-01 00:00 | 1200 |
| 106 | 2026-05-08 09:15 | 500 |
| 103 | 2026-05-14 12:30 | 800 |
| 104 | 2026-05-31 18:45 | 3000 |
| 105 | 2026-06-01 00:10 | 1500 |
[当月1日, 翌月1日)、日なら [当日0時, 翌日0時)、終了値は 開始 + INTERVAL '1 month' で計算すれば月末が28日でも31日でも考える必要がありません。'2026-05-31' を渡すと上限が「末日の午前0時」になり、その日のデータがほぼ全部落ちます。'23:59:59' を付ける対症療法もミリ秒以下の精度で穴が残ります。半開区間に統一すれば、この議論自体が消滅します。CREATE INDEX ON orders (DATE_TRUNC('month', ordered_at)) という式インデックスも選べます。ただし半開区間なら同じ素の index で月・日・任意期間すべてに対応できるため、汎用性では範囲条件が上。式インデックスは「特定の形のクエリが圧倒的多数」という根拠があるときの選択肢です。WHERE ordered_at::date = '2026-05-14' は手軽に見えますが、列側のキャスト = 前作暗黙キャストと同罪で index が死にます。正しくは ordered_at >= '2026-05-14' AND ordered_at < '2026-05-15' の1日幅の半開区間です。TO_CHAR(ordered_at, 'YYYY-MM') = '2026-05' は「関数で列を包む + 文字列比較」の二重苦で、index・統計情報・範囲比較のすべてを捨てる最悪手です。画面表示用の整形(TO_CHAR)と絞り込み条件は役割が違う——整形は SELECT 句、絞り込みは生の列で。timestamptz(タイムゾーン付き)の場合、DATE '2026-05-01' がどの瞬間を指すかはセッションのタイムゾーン設定に依存します。日本のサービスでもサーバーが UTC 設定だと「5月の売上」が9時間ズレて集計される事故は定番です。レポートSQLでは ordered_at >= TIMESTAMPTZ '2026-05-01 00:00:00+09' のように基準タイムゾーンを明示するか、チームで SET timezone の規約を固めておきましょう。次の Q4 では、JOIN が行を「増やす」ことで起きる重複と、それに DISTINCT で蓋をするコストを解剖します。JOIN のファンアウトと DISTINCT — 重複は「消す」より「作らない」
1対多の JOIN は、左テーブルの1行を右テーブルのマッチ行数ぶん複製します(ファンアウト)。「注文したことがある顧客の一覧」を JOIN で書くと顧客が注文数だけ重複し、それを DISTINCT で潰す——つまり重複を大量に作ってから、お金を払って消しているわけです。存在を確認したいだけなら EXISTS(Semi Join)で、最初の1件が見つかった瞬間に探索を打ち切り、重複をそもそも発生させないのが正解です。
-- ✗ JOIN で行を膨らませてから DISTINCT で蓋をする SELECT DISTINCT u.user_id, u.name FROM users u JOIN orders o ON o.user_id = u.user_id -- ✓ EXISTS:1件見つかれば十分、と DB に伝える(Semi Join) SELECT u.user_id, u.name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id)
users(実体10万行)と orders(実体500万行、user_id に index あり)から、1件でも注文したことがある顧客の一覧を取得してください。ただし 重複行をそもそも発生させない形(DISTINCT を使わない形)で書くこと。出力列は user_id, name(user_id 昇順)。
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 1 | 3000 |
| 104 | 2 | 500 |
| 105 | 2 | 700 |
| 106 | 3 | 300 |
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
SELECT u.user_id, u.name FROM users u WHERE EXISTS ( SELECT 1 -- 存在の真偽だけが欲しい(値は使われない) FROM orders o WHERE o.user_id = u.user_id ) ORDER BY u.user_id; /* 実行順序: 1. FROM users → 外側を走査 2. WHERE EXISTS → idx で1件見つけたら打ち切り(Semi Join) 3. TRUE のユーザーだけ通過 → 結合行を作らず重複なし 4. SELECT / ORDER BY → 2列を確定し user_id 昇順 */
LEGEND
1. 対象テーブル — 顧客と注文は1対多
users(4行) × orders(6行)Sato は3注文、Suzuki は2注文、Tanaka は1注文、Ito は0注文という偏った1対多の関係です。欲しい答えは「注文がある顧客3人」——orders の中身(金額や件数)は1つも要りません。| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 1 | 3000 |
| 104 | 2 | 500 |
| 105 | 2 | 700 |
| 106 | 3 | 300 |
COUNT(DISTINCT u.user_id) で数え直すのは、膨張コストを払った上に重い DISTINCT 集計まで重ねる二重課金です。集計が目的ならJOIN の前に集約する(事前集約)か、存在確認に切り出す(EXISTS)——発生源を断つのが正解です。部分インデックス — 偏った列は「少数派だけ」を索引化する
status のように値の分布が極端に偏る列(99% が 'done'、0.5% が 'pending' など)では、全行を索引化する通常 index はエントリの大半が一度も読まれない死荷重です。部分インデックス(Partial Index)は CREATE INDEX ... WHERE 条件 で条件を満たす行だけを索引化する仕組み。サイズは激減し、書込みコストも対象行に限定されます。
-- 通常 index:100万行ぶんのエントリ(99% は使われない 'done') CREATE INDEX idx_tasks_status ON tasks (status); -- 部分 index:'pending' の約5000行だけを created_at 順に索引化 CREATE INDEX idx_tasks_pending ON tasks (created_at) WHERE status = 'pending';
tasks テーブル(実体100万行、status の 99% は 'done'、'pending' は約0.5%)に対し、「未処理タスクを作成日時の古い順に20件」表示する画面が高頻度で呼ばれます。この画面専用の部分インデックスを作成し、それが効く形の SELECT を書いてください。出力列は task_id, title, created_at。
| task_id | title | status | created_at |
|---|---|---|---|
| 1 | レポート作成 | done | 2026-05-01 09:00 |
| 2 | 請求書確認 | pending | 2026-05-03 10:00 |
| 3 | バグ修正 | done | 2026-05-05 11:00 |
| 4 | 見積回答 | done | 2026-05-06 15:00 |
| 5 | 在庫棚卸 | pending | 2026-05-02 14:00 |
| 6 | メール返信 | done | 2026-05-07 08:30 |
| 7 | 契約更新 | in_progress | 2026-05-04 13:00 |
| 8 | データ移行 | done | 2026-05-08 16:20 |
| task_id | title | created_at |
|---|---|---|
| 5 | 在庫棚卸 | 2026-05-02 14:00 |
| 2 | 請求書確認 | 2026-05-03 10:00 |
CREATE INDEX idx_tasks_pending ON tasks (created_at) -- キー = 並び順に使う列(ソート工程を消す) WHERE status = 'pending'; -- 索引化するのは少数派の 'pending' だけ SELECT task_id, title, created_at FROM tasks WHERE status = 'pending' -- index の WHERE と同じ条件 → 部分 index が使える ORDER BY created_at LIMIT 20; /* 実行順序: 1. WHERE と index 条件の含意を判定 → 部分 index を採用 2. idx_tasks_pending を先頭から読む → created_at 昇順に並ぶ 3. ヒープから列を取得し LIMIT で打ち切り → ソート工程なし */
LEGEND
1. 対象テーブル — status の分布が極端に偏っている
FROM tasks(done が99%)完了タスクは増え続け、未処理はごく少数——キュー系テーブルの典型的な姿です。画面が知りたいのは常に少数派の 'pending' だけ。多数派のために index 容量を払う理由がありません。| task_id | title | status | created_at |
|---|---|---|---|
| 1 | レポート作成 | done | 2026-05-01 09:00 |
| 2 | 請求書確認 | pending | 2026-05-03 10:00 |
| 3 | バグ修正 | done | 2026-05-05 11:00 |
| 4 | 見積回答 | done | 2026-05-06 15:00 |
| 5 | 在庫棚卸 | pending | 2026-05-02 14:00 |
| 6 | メール返信 | done | 2026-05-07 08:30 |
| 7 | 契約更新 | in_progress | 2026-05-04 13:00 |
| 8 | データ移行 | done | 2026-05-08 16:20 |
created_at にしました。絞り込みは WHERE 句(定義条件)が担当するので、キーは並び替え・範囲条件に使う列に割り当てられます。結果、ORDER BY created_at LIMIT 20 が「index の先頭から20件読む」だけで完結し、既習の「ソート工程を消す」が部分 index でも成立します。CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL とすれば「論理削除されていない行の中でだけメールアドレスは一意」のような条件付き一意制約が実現できます。性能の道具であると同時に、通常の UNIQUE 制約では書けない業務ルールを表現するスキーマ設計の道具でもあります。(status) 全体への index は、多数派の 'done' を検索しても選択率が高すぎてプランナに選ばれず(Seq Scan の方が安い)、少数派検索のためだけに100万エントリを維持する羽目になります。「index を貼った」事実ではなく、EXPLAIN でそれが使われ、サイズが見合っているかまで確認して完了です。status IN ('pending', 'retry') になった瞬間、含意が成立せず部分 index は静かに使われなくなります。部分 index の定義条件とクエリは設計としてペアで管理し、条件変更時は EXPLAIN での回帰確認をセットにしてください。また status = $1 のようなパラメータ化された条件では含意を証明できない点にも注意が必要です。