期間条件付き Anti Join — 「直近90日に参加がない会員」を NOT EXISTS で正しく速く
NOT EXISTS は「対応する行が一度も存在しない」対象を取得できます。実務ではさらに、「直近90日に参加がない(= 休眠)」のような期間条件付きの除外が頻出します。鍵は追加条件をサブクエリの内側に書くこと——「90日以内の参加が存在しない」を素直に表現でき、プランナは Hash Anti Join + 複合 index による絞り込みを選べます。
-- ✗ NOT IN + 期間条件: NULL が混ざると結果0行 WHERE member_id NOT IN (SELECT member_id FROM event_entries WHERE entered_at >= DATE '2026-03-14') -- ✓ 条件はサブクエリ内側へ: NULL 安全 + Anti Join WHERE NOT EXISTS (SELECT 1 FROM event_entries e WHERE e.member_id = m.member_id AND e.entered_at >= DATE '2026-03-14')
member_id の一致 AND 期間内を内側に書けば「期間内の参加が無い人」、外側に書こうとすると(除外なのに)意味が壊れます。除外条件の粒度はすべてサブクエリ内側で完結させるのが原則です。members テーブル(実体10万行)と event_entries テーブル(実体300万行・member_id は NULL 許容でゲスト参加を表す)があります。status が active で、かつ 2026-03-14 以降に一度も参加していない「休眠会員」を、NULL に壊されず Anti Join に最適化される形で取得してください。出力列は member_id, name(member_id 昇順)。あわせて、サブクエリ側の照合を速くする複合 index も定義してください。
| member_id | name | status |
|---|---|---|
| 1 | Aoki | active |
| 2 | Baba | active |
| 3 | Chiba | inactive |
| 4 | Doi | active |
| 5 | Endo | active |
| entry_id | member_id | entered_at |
|---|---|---|
| 1 | 2 | 2026-05-10 |
| 2 | 4 | 2026-01-20 |
| 3 | NULL | 2026-05-01 |
| 4 | 5 | 2026-06-01 |
| 5 | 2 | 2026-02-11 |
| member_id | name |
|---|---|
| 1 | Aoki |
| 4 | Doi |
FILTER × GROUP BY のピボット集計 — カテゴリ別の件数・売上・完了率を1走査で
基礎編2では FILTER で「全体の条件別集計」を1走査にまとめました。応用編では GROUP BY と組み合わせたクロス集計(ピボット)に進みます。さらに実務レポートで必ず登場する「比率」——完了率・解約率など——も、集約値同士の式として同じ1走査の中で計算できます。落とし穴は2つ:整数同士の除算は0に切り捨てられること、該当行ゼロのグループで SUM が NULL になることです。
-- ✗ 整数除算: 2 / 3 = 0(小数が消える)/ 分母0なら division by zero COUNT(*) FILTER (WHERE …) / COUNT(*) -- ✓ 100.0 を掛けて numeric 化 + NULLIF で分母0を NULL に逃がす ROUND(100.0 * COUNT(*) FILTER (WHERE …) / NULLIF(COUNT(*), 0), 1)
NULLIF(a, b) は a = b なら NULL、それ以外は a を返します。分母を NULLIF(分母, 0) で包めば、分母0のとき式全体が NULL になりエラーで落ちません。同様に、該当行ゼロで NULL になる SUM(…) FILTER は COALESCE(…, 0) で0に戻すのがレポートの作法です。orders テーブル(実体500万行)から、1回の走査だけで category ごとに次の4つを集計してください:total_cnt(全件数)、completed_cnt(completed の件数)、completed_amt(completed の売上合計・該当なしは 0)、completed_rate(完了率% を小数1桁・分母0でも落ちない式)。出力は category 昇順。
| order_id | category | status | amount |
|---|---|---|---|
| 1 | book | completed | 1200 |
| 2 | book | cancelled | 800 |
| 3 | food | completed | 500 |
| 4 | food | completed | 700 |
| 5 | food | completed | 300 |
| 6 | toy | pending | 900 |
| 7 | book | completed | 2000 |
| category | total_cnt | completed_cnt | completed_amt | completed_rate |
|---|---|---|---|---|
| book | 3 | 2 | 3200 | 66.7 |
| food | 3 | 3 | 1500 | 100.0 |
| toy | 1 | 0 | 0 | 0.0 |
複合 index で WHERE + ORDER BY を一撃に — キーセットページネーションまで
基礎編2では単一列 index で ORDER BY のソートを消しました。実務の一覧画面はほぼ必ず「絞り込み + 並べ替え + ページ送り」のセットです。WHERE category = 'tech' ORDER BY published_at DESC LIMIT 3 のような形は、複合 index (category, published_at DESC) で「等値で区間に飛ぶ → 区間内は整列済み → N件で打ち切り」が一撃になります。さらにページ送りは OFFSET でなくキーセット方式(前ページ最終行の値を境界に使う)にすると、深いページでも速度が落ちません。
-- ✗ OFFSET: 捨てる行も全部読む(100ページ目 = 297行を読んで捨て、3行返す) ORDER BY published_at DESC LIMIT 3 OFFSET 297 -- ✓ keyset: 前ページ最終行の値から index で続きに飛ぶ WHERE category = 'tech' AND published_at < '2026-06-02' ORDER BY published_at DESC LIMIT 3
category(等値)を先頭に置くと、tech のエントリが連続区間にまとまり、その区間内は published_at DESC で整列済み。逆順の (published_at DESC, category) だと tech が index 全域に散らばり、ソートは消えても不要カテゴリの読み飛ばしが発生します。articles テーブル(実体200万行)から、category = 'tech' の最新記事3件(1ページ目)を published_at 降順で取得してください。あわせて、(1) このクエリのソート工程を消す複合インデックス、(2) OFFSET を使わない2ページ目のクエリ(1ページ目の最終行が published_at = '2026-06-02' だったとする)も書いてください。出力列は article_id, title, published_at。
| article_id | category | title | published_at |
|---|---|---|---|
| 1 | life | 朝の習慣術 | 2026-05-01 |
| 2 | tech | インデックス設計 | 2026-06-09 |
| 3 | life | 休日の整え方 | 2026-04-12 |
| 4 | tech | JOINの基礎 | 2026-06-11 |
| 5 | life | 睡眠の科学 | 2026-03-30 |
| 6 | tech | ソートとメモリ | 2026-06-02 |
| 7 | tech | CTE活用 | 2026-05-20 |
| 8 | tech | 統計情報入門 | 2026-04-25 |
期待する出力①:
| article_id | title | published_at |
|---|---|---|
| 4 | JOINの基礎 | 2026-06-11 |
| 2 | インデックス設計 | 2026-06-09 |
| 6 | ソートとメモリ | 2026-06-02 |
期待する出力②:
| article_id | title | published_at |
|---|---|---|
| 7 | CTE活用 | 2026-05-20 |
| 8 | 統計情報入門 | 2026-04-25 |
月別テーブル横断の最新N件 — ブランチごとの Top-N と Merge Append
基礎編2では「排他ソースの統合は UNION ALL」を学びました。応用編はその先——統合した結果から最新N件だけ欲しいケースです。素朴に書くと「全部つなげてから全体をソート」になりますが、各テーブルに created_at の index があるなら、各ブランチで先に Top-N を取り(最大 N×ブランチ数 行)、外側で並べ直して N 件にすれば、全件ソートを丸ごと回避できます。PostgreSQL は条件が揃えば Merge Append(整列済みストリームのマージ)まで使ってくれます。
-- ✗ 全件 Append → 600万行を Sort → 3件 Limit → Sort (600万行) → Append → Seq Scan ×2 -- ✓ 各ブランチ index で3件 → 高々6行を並べて3件 Limit → Sort/Merge (≤6行) → Append ├─ Limit → Index Scan Backward (logs_202605) └─ Limit → Index Scan Backward (logs_202606)
ログが月別テーブル logs_202605 / logs_202606(実体それぞれ300万行・ログIDの体系は排他・各テーブルに created_at の降順 index あり)に分かれています。2テーブルを横断した最新ログ3件を、全件ソートを発生させない形で取得してください。出力列は log_id, message, created_at(created_at 降順)。
| log_id | message | created_at |
|---|---|---|
| 101 | deploy ok | 2026-05-28 |
| 102 | cache warm | 2026-05-30 |
| 103 | batch done | 2026-05-15 |
| log_id | message | created_at |
|---|---|---|
| 201 | deploy ok | 2026-06-10 |
| 202 | index rebuilt | 2026-06-03 |
| 203 | backup done | 2026-06-11 |
| log_id | message | created_at |
|---|---|---|
| 203 | backup done | 2026-06-11 |
| 201 | deploy ok | 2026-06-10 |
| 202 | index rebuilt | 2026-06-03 |
総合問題 — 月別統合 + NULL安全除外 + FILTERピボット + HAVING のレポートSQL
応用編2の総仕上げです。月次売上レポートを、今回学んだ部品のフル合成で組み立てます:① 月別テーブルの統合は UNION ALL、② ブロック済みアカウントの除外は NOT EXISTS(NULL 安全 + Anti Join)、③ region × status のピボットと金額は FILTER + COALESCE、そして新登場の ④ グループの選別は HAVING——集約値(FILTER 付き!)を条件に使えます。
-- 組み立ての型(データの流れ = 実行順序) FROM ( ... UNION ALL ... ) s -- ① 統合(Append のみ) WHERE NOT EXISTS ( ... ) -- ② 行の除外(集計前・上流で) GROUP BY ... -- ③ グループ化 + FILTER 集計 HAVING 集約 FILTER (...) >= n -- ④ グループの選別(集計後) ORDER BY 集約列 DESC
月別売上 sales_202605 / sales_202606(ID体系は排他・実体それぞれ数百万行)を統合し、ブロック済みアカウントの売上を除外した上で、region ごとに paid 件数・paid 売上合計・refund 件数を集計してください。ただし paid が2件以上の region のみを、paid 売上合計の降順で出力します。blocked_accounts.customer_id は NULL 許容(審査中の行が混ざる)です。出力列は region, paid_cnt, paid_amt, refund_cnt。
| sale_id | region | status | amount | customer_id |
|---|---|---|---|---|
| 1 | east | paid | 1000 | 11 |
| 2 | west | paid | 1500 | 12 |
| 3 | east | refund | 400 | 13 |
| 4 | north | paid | 300 | 15 |
| sale_id | region | status | amount | customer_id |
|---|---|---|---|---|
| 11 | east | paid | 2000 | 11 |
| 12 | west | refund | 700 | 999 |
| 13 | east | paid | 800 | 14 |
| 14 | west | paid | 1200 | 12 |
| block_id | customer_id |
|---|---|
| 1 | 999 |
| 2 | NULL |
| region | paid_cnt | paid_amt | refund_cnt |
|---|---|---|---|
| east | 3 | 3800 | 1 |
| west | 2 | 2700 | 0 |