SQL パフォーマンス最適化 — 三値論理・部分INDEXの基礎

基礎相関サブクエリとN+1三値論理 / IS DISTINCT FROM日付の半開区間Semi Join部分インデックスPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

相関サブクエリと N+1 — SELECT句のサブクエリは「行数ぶん」実行される

相関サブクエリN+1実行回数JOIN + GROUP BY
前提知識

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 ANALYZESubPlanloops=N(Nが外側の行数)が出ていたら N+1 です。クエリの形は「1文」でも、実行の中身はループになっています。
問題

users(実体10万行)と orders(実体500万行)から、ユーザーごとの注文件数と合計金額を取得してください。ただし orders への走査がユーザー数に比例しない(テーブル走査が各1回で済む)形で書くこと。注文ゼロのユーザーも 件数0・合計0 で出力します。出力列は user_id, name, order_cnt, total_amount(user_id 昇順)。

使用テーブル
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
- orders(user_id に index あり)
order_iduser_idamount
10111200
1021800
10323000
1043500
1053700
1063300
期待出力
user_idnameorder_cnttotal_amount
1Sato22000
2Suzuki13000
3Tanaka31500
4Ito00
模範解答コード
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 昇順に整列
  */
解説(テーブル変化・ポイント)
SELECT u.user_id, u.name, COUNT(o.order_id) AS order_cnt, COALESCE(SUM(o.amount), 0) AS total_amount 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;
LEGEND
データ取得・読込対象
1. 対象テーブル — users(外側になる側)
FROM users u求めたいのは「ユーザー1人につき1行」の集計結果です。注目すべきは外側になる users の行数。相関サブクエリの実行回数は、この行数に正比例します。
1 / 5
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
users 4行 / orders 6行(実体は10万 × 500万)
学習ポイント
コスト = 1回の重さ × 実行回数:相関サブクエリの総コストは「サブクエリ1回のコスト × 外側の行数」の掛け算です。index 探索が1回 0.1ms でも、10万行 × 2本なら20秒。チューニングの第一歩は「速くする」前に「何回実行されているか」を数えること。EXPLAIN ANALYZESubPlan 配下にある loops=100000 がその証拠です。
書き換えの定石は「集合で1回」:JOIN + GROUP BY の形は、走査が各テーブル1回 + 集約1パスで完結します。JOIN + GROUP BY の文法自体は既習のとおりですが、本問の論点は同じ結果を返す2つの書き方の「実行回数」の差。複数の集計列(COUNT と SUM)が同じ1回の集約に相乗りできる点も、サブクエリを列ごとに増やす書き方との決定的な違いです。
相関サブクエリが常に悪ではない:PostgreSQL のプランナは、条件次第でサブクエリをハッシュ化(hashed SubPlan)したり Semi Join に変換したりして N+1 を回避します。また「グループごとの上位N件」のように相関が本質的な要件なら LATERAL が適任です。重要なのは形の善悪の暗記ではなく、EXPLAIN で実際の実行回数を確認する習慣です。
アンチパターン
SELECT句にサブクエリを「1列ずつ」足して育てる:「件数も欲しい」「合計も」「最終注文日も」と要望のたびにサブクエリを足すと、列が増えるごとに全行ループが1本増える静かな劣化が進みます。集計列が2つ以上になった時点で JOIN + GROUP BY(または1つの導出テーブル)への統合を検討してください。
アプリ側ループでSQLを連発する(ORMのN+1):ユーザー一覧を取った後、画面のループ内で1人ずつ SELECT SUM(...) を発行するのは、本問のNG形をネットワーク往復付きでさらに遅くしたものです。SQL内のN+1とアプリのN+1は同根——「まとめて1回」の原則はレイヤーを問わず効きます。
実務コラム:「行で考える」から「集合で考える」へ
手続き型言語の経験者ほど「1行ずつ処理する」発想でSQLを書きがちで、相関サブクエリはその発想がそのまま形になったものです。RDBMSが最も得意なのは集合をまとめて変換すること。「各〜について…を求める」という要件を見たら、まず「全員ぶんを一括で集計してから突き合わせられないか」と問い直すのが集合指向の第一歩です。なお実行回数の罠は WHERE 句にも潜みますが、もう1つの頻出の落とし穴が「比較結果が TRUE にならない行が黙って消える」NULL の三値論理です。次の Q2 で見ていきます。
QUESTION 2

NULL と三値論理 — 「等しくない」比較は NULL を黙って落とす

NULL三値論理IS DISTINCT FROM正しさ
前提知識

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)
核心:NULL は「不明」を表すマーカーです。「不明な値」と比較した結果も「不明(UNKNOWN)」になり、NOT を付けても UNKNOWN は UNKNOWN のまま。「除外条件を書いたら件数まで減った」事故の大半はここが原因です。
問題

orders テーブルの status 列は NULL 許可です(旧システムから移行した注文は status 未設定 = NULL)。「キャンセル以外の注文」をすべて取得してください。status が NULL の注文も「キャンセルではない」として含めること。出力列は order_id, status, amount(order_id 昇順)。

使用テーブル
- orders(status は NULL 許可)
order_idstatusamount
1paid1200
2cancelled3000
3NULL800
4shipped500
5cancelled700
6NULL2000
期待出力
order_idstatusamount
1paid1200
3NULL800
4shipped500
6NULL2000
模範解答コード
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列(取りこぼしゼロ)
*/
解説(テーブル変化・ポイント)
SELECT order_id, status, amount FROM orders WHERE status IS DISTINCT FROM 'cancelled' ORDER BY order_id;
LEGEND
データ取得・読込対象
1. 対象テーブル — status に NULL が混ざっている
FROM ordersid=3, 6 の status は NULL(未設定)。実務では「旧システム移行分」「項目追加前の既存行」など、NULL 許可列に NULL が実在するのはごく普通の状態です。この前提を忘れた瞬間に事故が起きます。
1 / 5
order_idstatusamount
1paid1200
2cancelled3000
3NULL800
4shipped500
5cancelled700
6NULL2000
6行(うち NULL が2行)
学習ポイント
WHERE は「TRUE のみ」を通すフィルタ:三値論理の実害は、UNKNOWN がFALSE と同じ「不通過」側に落ちることです。肯定条件(= 'paid')では NULL 行が落ちても直感に合いますが、否定・除外条件(<> / NOT LIKE / NOT IN)では「除外したい値以外まで消える」ため事故になります。既習の「NOT IN と NULL の罠」はサブクエリ側の NULL でしたが、本問は列側の NULL——同じ三値論理の別の顔です。
IS DISTINCT FROM は「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 をどちらに含めるか」を必ず明示するのが本質です。
性能の観点 — 除外条件は index の出番ではない:<> 'cancelled' のような除外条件は通常ほぼ全行が該当するため(選択率が高い)、index を使うより Seq Scan が合理的とプランナは判断します。つまり本問の主戦場は速度ではなく正しさ。逆に「cancelled だけ抽出」のような少数派の抽出は index が最も輝く場面で、部分インデックスに直結します。
アンチパターン
COALESCE で包んで NULL を吸収する:COALESCE(status, '') <> 'cancelled' は一見正しく動きますが、列を関数で包む Sargable 違反(前作同じ構図)であり、「空文字と NULL を同一視する」という暗黙仕様をクエリ側に焼き込んでしまいます。比較の意味は IS DISTINCT FROMIS NULL で明示するのが筋です。
NULL を放置したままクエリ側だけで戦う:本来 status が必須項目なら、NOT NULL 制約 + DEFAULT をスキーマに付けるのが本筋です。NULL を許すなら「未設定?不明?対象外?」というNULL の業務的な意味を仕様として文書化しないと、開発者ごとに解釈が割れて集計値がブレ続けます。
実務コラム:NULL の三値論理はあらゆる句に波及する
三値論理の影響は WHERE だけではありません。JOIN の ON 句(NULL キーは結合しない)、集約COUNT(col) は NULL を数えない・AVG の分母から外れる)、CHECK 制約(UNKNOWN は通ってしまう)、そして既習の NOT IN。新しいテーブルを触るときは最初に \d テーブル名NOT NULL 制約の有無を確認する習慣をつけると、この種の事故を設計段階で潰せます。次の Q3 では、正しさと速さの両方が絡むもう1つの頻出地帯——日付・時刻の範囲条件を扱います。
QUESTION 3

日付条件の Sargable 化 — 関数で切らず「半開区間」で絞る

日付範囲半開区間SargableRange Scan
前提知識

「2026年5月の注文」を DATE_TRUNCEXTRACT で書くと、列が関数に包まれて 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'
半開区間 [開始, 終了) の覚え方:「開始は含む・終了は含まない」。終了値に翌月1日・翌日0時を置けば、23:59:59.999… のような端数を一切考えずに済み、隣り合う期間が隙間なく連結します。
問題

orders テーブル(実体800万行)の ordered_at 列は timestamp 型で、インデックス idx_orders_ordered_at があります。2026年5月分の注文件数と合計金額を、Index Range Scan が効き、かつ月末のデータを取りこぼさない形で集計してください。出力列は order_cnt, total_amount

使用テーブル
- orders(ordered_at は timestamp / index あり)
order_idordered_atamount
1012026-04-30 23:50900
1022026-05-01 00:001200
1032026-05-14 12:30800
1042026-05-31 18:453000
1052026-06-01 00:101500
1062026-05-08 09:15500
期待出力
order_cnttotal_amount
45500
模範解答コード
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パスで集計
  */
解説(テーブル変化・ポイント)
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';
LEGEND
データ取得・読込対象
1. 対象テーブル — index は時刻順に整列している
FROM orders(idx_orders_ordered_at)idx_orders_ordered_at の中では行が時刻順の連続した並びになっています。「2026年5月」という期間は、この並びの上ではひとつながりの区間——ここが半開区間書き換えの物理的な根拠です。
1 / 5
order_idordered_atamount
1012026-04-30 23:50900
1022026-05-01 00:001200
1062026-05-08 09:15500
1032026-05-14 12:30800
1042026-05-31 18:453000
1052026-06-01 00:101500
6行を時刻順に表示(実テーブルは800万行)
学習ポイント
「期間」は関数の結果ではなく連続区間:index の中で行は時刻順に並んでおり、月・日・年度といった期間は必ずひとつながりの区間になります。だから書き換えは機械的——月なら [当月1日, 翌月1日)、日なら [当日0時, 翌日0時)、終了値は 開始 + INTERVAL '1 month' で計算すれば月末が28日でも31日でも考える必要がありません
BETWEEN は timestamp で事故る:BETWEEN は両端を含む閉区間です。date 型同士なら安全ですが、timestamp 列に '2026-05-31' を渡すと上限が「末日の午前0時」になり、その日のデータがほぼ全部落ちます'23:59:59' を付ける対症療法もミリ秒以下の精度で穴が残ります。半開区間に統一すれば、この議論自体が消滅します
どうしても関数で切りたいなら式インデックス:「月単位の集計しかしない」と決まっているなら CREATE INDEX ON orders (DATE_TRUNC('month', ordered_at)) という式インデックスも選べます。ただし半開区間なら同じ素の index で月・日・任意期間すべてに対応できるため、汎用性では範囲条件が上。式インデックスは「特定の形のクエリが圧倒的多数」という根拠があるときの選択肢です。
アンチパターン
::date キャストで日付比較する:WHERE ordered_at::date = '2026-05-14' は手軽に見えますが、列側のキャスト = 前作暗黙キャストと同罪で index が死にます。正しくは ordered_at >= '2026-05-14' AND ordered_at < '2026-05-15' の1日幅の半開区間です。
TO_CHAR で文字列にして比較する:TO_CHAR(ordered_at, 'YYYY-MM') = '2026-05' は「関数で列を包む + 文字列比較」の二重苦で、index・統計情報・範囲比較のすべてを捨てる最悪手です。画面表示用の整形(TO_CHAR)と絞り込み条件は役割が違う——整形は SELECT 句、絞り込みは生の列で
実務コラム:タイムゾーンが絡むと「5月」の定義が揺れる
列が timestamptz(タイムゾーン付き)の場合、DATE '2026-05-01' がどの瞬間を指すかはセッションのタイムゾーン設定に依存します。日本のサービスでもサーバーが UTC 設定だと「5月の売上」が9時間ズレて集計される事故は定番です。レポートSQLでは ordered_at >= TIMESTAMPTZ '2026-05-01 00:00:00+09' のように基準タイムゾーンを明示するか、チームで SET timezone の規約を固めておきましょう。次の Q4 では、JOIN が行を「増やす」ことで起きる重複と、それに DISTINCT で蓋をするコストを解剖します。
QUESTION 4

JOIN のファンアウトと DISTINCT — 重複は「消す」より「作らない」

DISTINCTファンアウトEXISTSSemi Join
前提知識

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)
判断基準:SELECT 句に右テーブルの列が1つも登場しない JOIN を見たら要注意。その JOIN の目的は「存在確認」であり、結合行を作る必要は最初からありません。
問題

users(実体10万行)と orders(実体500万行、user_id に index あり)から、1件でも注文したことがある顧客の一覧を取得してください。ただし 重複行をそもそも発生させない形(DISTINCT を使わない形)で書くこと。出力列は user_id, name(user_id 昇順)。

使用テーブル
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
- orders(user_id に index あり)
order_iduser_idamount
10111200
1021800
10313000
1042500
1052700
1063300
期待出力
user_idname
1Sato
2Suzuki
3Tanaka
模範解答コード
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 昇順
  */
解説(テーブル変化・ポイント)
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;
LEGEND
データ取得・読込対象
1. 対象テーブル — 顧客と注文は1対多
users(4行) × orders(6行)Sato は3注文、Suzuki は2注文、Tanaka は1注文、Ito は0注文という偏った1対多の関係です。欲しい答えは「注文がある顧客3人」——orders の中身(金額や件数)は1つも要りません。
1 / 5
order_iduser_idamount
10111200
1021800
10313000
1042500
1052700
1063300
users 4行 / orders 6行(実体は10万 × 500万)
学習ポイント
ファンアウトの検知法:JOIN 後の行数が左テーブルの行数を超えたら1対多の複製が起きています。重複行だけでなく、JOIN 後に集計すると COUNT や SUM が水増しされる事故(注文明細を JOIN したら売上が明細数倍になった等)も同じファンアウトが原因。「この JOIN は行を増やすか?」を結合キーの多重度から先に見積もる癖をつけましょう。
Semi Join は「1件で十分」という意図の宣言:EXISTS で書くと、プランナは内側の探索を最初のマッチで打ち切る Semi Join を選べます。JOIN + DISTINCT では「全結合行が必要かもしれない」と解釈され、打ち切れません。同じ結果でも、意図を正確に伝えた方が良い計画が出る——SQLは「何が欲しいか」の宣言文だからです。既習の NOT EXISTS(Anti Join)と対になる、存在側の道具として整理しておきましょう。
DISTINCT が正当な場面もある:「結果として重複しうる値の一覧が欲しい」(例:注文実績のある配送先都道府県の一覧)なら DISTINCT は正攻法です。問題なのはJOIN の副作用で生まれた重複を、原因を理解せずに消す使い方。DISTINCT を書く手が動いたら「この重複はどこから来たか」を一度言語化するのが安全装置になります。
アンチパターン
「とりあえず SELECT DISTINCT」を常備する:原因不明の重複に DISTINCT で蓋をすると、その場は直っても後から JOIN が1本増えた時に重複の組み合わせが乗算で爆発し、突然遅くなります。蓋の下で何が起きているか誰も知らない状態が一番危険です。
COUNT(DISTINCT ...) で水増しを補正し続ける:ファンアウトした結果を COUNT(DISTINCT u.user_id) で数え直すのは、膨張コストを払った上に重い DISTINCT 集計まで重ねる二重課金です。集計が目的ならJOIN の前に集約する(事前集約)か、存在確認に切り出す(EXISTS)——発生源を断つのが正解です。
実務コラム:「行を増やす JOIN」と「絞る JOIN」を区別する
JOIN には大きく2つの役割があります。列を足す JOIN(1対1・多対1:注文に顧客名を付ける)と、行を増やしうる JOIN(1対多:顧客に注文明細をぶら下げる)。前者は安全、後者は集計・件数・重複のすべてに影響します。レビューで JOIN を見たら結合キーの多重度を必ず確認し、行を増やす JOIN が「存在確認」目的なら EXISTS へ、「集計」目的なら JOIN 前の事前集約へ逃がす——この仕分けだけで重複系バグの大半は防げます。最後の Q5 では、検索を速くする側の道具として「偏った列」に効く部分インデックスを学びます。
QUESTION 5

部分インデックス — 偏った列は「少数派だけ」を索引化する

部分インデックス選択率CREATE INDEXIndex Scan
前提知識

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';
使われる条件:クエリの WHERE 句が index の WHERE 条件を含意していること(クエリの条件を満たす行が必ず index に入っている、とプランナが証明できること)。条件がズレた瞬間に index は候補から外れます。
問題

tasks テーブル(実体100万行、status の 99% は 'done'、'pending' は約0.5%)に対し、「未処理タスクを作成日時の古い順に20件」表示する画面が高頻度で呼ばれます。この画面専用の部分インデックスを作成し、それが効く形の SELECT を書いてください。出力列は task_id, title, created_at

使用テーブル
- tasks(status は 'done' に極端に偏る)
task_idtitlestatuscreated_at
1レポート作成done2026-05-01 09:00
2請求書確認pending2026-05-03 10:00
3バグ修正done2026-05-05 11:00
4見積回答done2026-05-06 15:00
5在庫棚卸pending2026-05-02 14:00
6メール返信done2026-05-07 08:30
7契約更新in_progress2026-05-04 13:00
8データ移行done2026-05-08 16:20
期待出力
task_idtitlecreated_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 で打ち切り     → ソート工程なし
  */
解説(テーブル変化・ポイント)
CREATE INDEX idx_tasks_pending ON tasks (created_at) WHERE status = 'pending'; SELECT task_id, title, created_at FROM tasks WHERE status = 'pending' ORDER BY created_at LIMIT 20;
LEGEND
データ取得・読込対象
1. 対象テーブル — status の分布が極端に偏っている
FROM tasks(done が99%)完了タスクは増え続け、未処理はごく少数——キュー系テーブルの典型的な姿です。画面が知りたいのは常に少数派の 'pending' だけ。多数派のために index 容量を払う理由がありません。
1 / 5
task_idtitlestatuscreated_at
1レポート作成done2026-05-01 09:00
2請求書確認pending2026-05-03 10:00
3バグ修正done2026-05-05 11:00
4見積回答done2026-05-06 15:00
5在庫棚卸pending2026-05-02 14:00
6メール返信done2026-05-07 08:30
7契約更新in_progress2026-05-04 13:00
8データ移行done2026-05-08 16:20
8行(実テーブルは100万行・pending 約5000行)
学習ポイント
部分 index は「読み・書き・メモリ」の三方良し:エントリが少数派だけなので B-tree が浅く読みが速い多数派の行の更新が index に波及しないため書込みが軽い、そしてサイズが小さいのでキャッシュに常駐しやすい。「status が 'done' になったらエントリから外れる」という自己清掃的な性質も、伸び続けるキュー系テーブルと相性抜群です。
キー列の設計で ORDER BY まで消す:本問の index はキーを status ではなく created_at にしました。絞り込みは WHERE 句(定義条件)が担当するので、キーは並び替え・範囲条件に使う列に割り当てられます。結果、ORDER BY created_at LIMIT 20 が「index の先頭から20件読む」だけで完結し、既習の「ソート工程を消す」が部分 index でも成立します。
UNIQUE 部分 index は制約としても強力:CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL とすれば「論理削除されていない行の中でだけメールアドレスは一意」のような条件付き一意制約が実現できます。性能の道具であると同時に、通常の UNIQUE 制約では書けない業務ルールを表現するスキーマ設計の道具でもあります。
アンチパターン
偏った列に通常 index を貼って満足する:(status) 全体への index は、多数派の 'done' を検索しても選択率が高すぎてプランナに選ばれず(Seq Scan の方が安い)、少数派検索のためだけに100万エントリを維持する羽目になります。「index を貼った」事実ではなく、EXPLAIN でそれが使われ、サイズが見合っているかまで確認して完了です。
index の定義条件とアプリのクエリが乖離する:後から要件が「pending と retry の両方を表示」に変わり、クエリが status IN ('pending', 'retry') になった瞬間、含意が成立せず部分 index は静かに使われなくなります。部分 index の定義条件とクエリは設計としてペアで管理し、条件変更時は EXPLAIN での回帰確認をセットにしてください。また status = $1 のようなパラメータ化された条件では含意を証明できない点にも注意が必要です。
実務コラム:基礎編3の総まとめ — 「正しく・速い」SELECT の5原則
Q1〜整理すると、1. サブクエリは「何回実行されるか」で見る(N+1 は JOIN + 集約へ)2. NULL 許可列の否定条件は三値論理を疑う(IS DISTINCT FROM / OR IS NULL)3. 日付は関数で切らず半開区間 [開始, 次の開始) で絞る4. 重複は DISTINCT で消すより Semi Join(EXISTS)で作らない5. 偏った列は部分インデックスで少数派だけ索引化する。前作までの原則(Sargable・LIKE 前方一致・OR の分解・WHERE と HAVING・カバリング index など)と合わせると、「index に乗る形に書く」「実行回数と中間結果のサイズを減らす」「NULL と境界値で正しさを守る」という3軸でチューニングの全体地図が描けます。続編では、これらを EXPLAIN の実出力で検証しながら、結合アルゴリズムの選択や統計情報の鮮度といった「プランナの気持ち」へ踏み込みます。