日付の半開区間 — 関数とBETWEENの罠を範囲条件で断つ
「2026年5月の注文」のような期間抽出で date_trunc('month', ordered_at) = '2026-05-01' と書くと、列が関数で包まれて index が使えません(Sargable 違反)。さらに BETWEEN '2026-05-01' AND '2026-05-31' もタイムスタンプ列では危険で、5月31日の 00:00:00 より後の行が全て漏れます。正解は半開区間(開始以上・終了「未満」)です。
-- ✗ 列を関数で包む → 全行で関数評価 = Seq Scan WHERE date_trunc('month', ordered_at) = '2026-05-01' -- ✗ BETWEEN は「両端を含む」→ 5/31 00:00:00 を超える行が漏れる WHERE ordered_at BETWEEN '2026-05-01' AND '2026-05-31' -- ✓ 半開区間:index が効き、月末の端まで正確 WHERE ordered_at >= '2026-05-01' AND ordered_at < '2026-06-01'
orders テーブル(実体300万行)の ordered_at(timestamp)にはインデックス idx_orders_ordered_at があります。2026年5月の注文を、index が効き、かつ月末の端数秒まで漏れなく ordered_at 昇順で取得してください。出力列は order_id, ordered_at, amount。
| order_id | ordered_at | amount |
|---|---|---|
| 1 | 2026-04-30 23:59:59 | 700 |
| 2 | 2026-05-01 00:00:00 | 3400 |
| 3 | 2026-05-14 12:30:00 | 800 |
| 4 | 2026-05-20 18:05:00 | 950 |
| 5 | 2026-05-31 23:59:59.9 | 2600 |
| 6 | 2026-06-01 00:00:00 | 1500 |
| order_id | ordered_at | amount |
|---|---|---|
| 2 | 2026-05-01 00:00:00 | 3400 |
| 3 | 2026-05-14 12:30:00 | 800 |
| 4 | 2026-05-20 18:05:00 | 950 |
| 5 | 2026-05-31 23:59:59.9 | 2600 |
複合インデックスの列順 — 「等値 → 範囲 → 並び」で設計する
複合インデックス (A, B) は「A で並べ、A が同じ中で B で並べる」電話帳(姓 → 名)構造です。この構造を最大限使える条件の形は「等値 → 範囲 → 並び替え」:先頭列を等値(=)で固定すると、その内側で次の列がきれいに整列した連続区間になり、範囲条件と ORDER BY までまとめて index に乗ります。
-- index (status, shipped_at) に対して: WHERE status = 'shipped' -- 1. 等値で先頭列を固定(区間を1点に) AND shipped_at >= '2026-06-01' -- 2. 固定した区間内の範囲走査 ORDER BY shipped_at -- 3. 区間内は整列済み → ソート不要
shipments テーブル(実体200万行)には複合インデックス idx_ship_status_date (status, shipped_at) があります。ステータスが 'shipped' で、2026年6月1日以降に出荷されたレコードを、この index だけで絞り込みとソートが完結する形で shipped_at 昇順に取得してください。出力列は ship_id, status, shipped_at, dest。
| ship_id | status | shipped_at | dest |
|---|---|---|---|
| 1 | pending | 2026-06-02 | Tokyo |
| 2 | shipped | 2026-05-28 | Osaka |
| 3 | shipped | 2026-06-01 | Tokyo |
| 4 | delivered | 2026-06-03 | Nagoya |
| 5 | shipped | 2026-06-04 | Sapporo |
| 6 | pending | 2026-05-30 | Fukuoka |
| 7 | shipped | 2026-06-09 | Tokyo |
| 8 | delivered | 2026-06-05 | Kobe |
| ship_id | status | shipped_at | dest |
|---|---|---|---|
| 3 | shipped | 2026-06-01 | Tokyo |
| 5 | shipped | 2026-06-04 | Sapporo |
| 7 | shipped | 2026-06-09 | Tokyo |
LATERAL JOIN — 「顧客ごとの最新2件」を index で取り切る
「顧客ごとの最新N件」は実務最頻出の要件ですが、素朴に書くと難物です。ウィンドウ関数(ROW_NUMBER())でも書けますが、対象顧客が少数なら全行スキャン + 全行ソートは過剰。LATERAL を使うと、左側の各行を引数のように受け取るサブクエリを書け、顧客1人ずつ「index で最新2件だけ拾って即終了」という最小走査が実現します。
FROM customers c CROSS JOIN LATERAL ( -- c の各行ごとに実行されるサブクエリ SELECT ... FROM orders WHERE customer_id = c.customer_id -- 外側の列を参照できる(LATERAL の特権) ORDER BY ordered_at DESC LIMIT 2 -- 顧客ごとに2件で打ち切り ) o
(customer_id, ordered_at DESC) があれば、各顧客のサブクエリは「等値で顧客区間に固定 → 整列済みの先頭から2行読んで終了」。注文が100万件あっても、走査は 顧客数 × 2行 で済みます。customers(3行)と orders(実体100万行、複合インデックス idx_orders_cust_date (customer_id, ordered_at DESC) あり)から、各顧客の最新2件の注文を LATERAL JOIN で取得してください。出力列は customer_id, name, order_id, ordered_at, amount、並びは customer_id 昇順・ordered_at 降順。
| customer_id | name |
|---|---|
| 101 | Sato |
| 102 | Suzuki |
| 103 | Tanaka |
| order_id | customer_id | ordered_at | amount |
|---|---|---|---|
| 1 | 101 | 2026-06-01 | 1200 |
| 2 | 102 | 2026-06-02 | 800 |
| 3 | 101 | 2026-06-03 | 3000 |
| 4 | 103 | 2026-06-04 | 500 |
| 5 | 101 | 2026-06-05 | 900 |
| 6 | 102 | 2026-06-06 | 1500 |
| 7 | 103 | 2026-06-07 | 700 |
| 8 | 101 | 2026-06-08 | 2200 |
| 9 | 102 | 2026-06-09 | 600 |
| customer_id | name | order_id | ordered_at | amount |
|---|---|---|---|---|
| 101 | Sato | 8 | 2026-06-08 | 2200 |
| 101 | Sato | 5 | 2026-06-05 | 900 |
| 102 | Suzuki | 9 | 2026-06-09 | 600 |
| 102 | Suzuki | 6 | 2026-06-06 | 1500 |
| 103 | Tanaka | 7 | 2026-06-07 | 700 |
| 103 | Tanaka | 4 | 2026-06-04 | 500 |
FILTER 句 — 1回の走査で複数の条件付き集約を畳み込む
「決済手段ごとに、全体件数と成功件数と失敗額合計を出す」——条件の違う集約が複数並ぶレポート要件です。テーブルを条件ごとに3回スキャンして JOIN するのは最悪手。PostgreSQL の FILTER 句を使えば、1回の走査の中で集約関数ごとに対象行を選り分けられます。
-- ✗ 条件ごとにスキャンを繰り返して JOIN(3パス) SELECT ... FROM (成功だけ集計) JOIN (失敗だけ集計) ON ... -- ✓ FILTER:1パスの中で集約ごとに行を選別 COUNT(*) FILTER (WHERE status = 'ok') -- この COUNT だけ ok 行を数える SUM(amount) FILTER (WHERE status = 'ng') -- この SUM だけ ng 行を足す
payments テーブル(実体800万行)から決済手段(method)ごとに、全体件数 cnt・成功件数 ok_cnt(status='ok')・失敗額合計 ng_amount(status='ng' の amount 合計、該当なしは 0) を1回の走査で集計し、件数が2以上の手段だけを method 昇順で取得してください。
| pay_id | method | status | amount |
|---|---|---|---|
| 1 | card | ok | 1200 |
| 2 | bank | ok | 5000 |
| 3 | card | ng | 800 |
| 4 | card | ok | 2400 |
| 5 | paypay | ok | 600 |
| 6 | bank | ng | 3000 |
| 7 | card | ng | 500 |
| 8 | bank | ok | 7000 |
| method | cnt | ok_cnt | ng_amount |
|---|---|---|---|
| bank | 3 | 2 | 3000 |
| card | 4 | 2 | 1300 |
部分インデックス — 「未処理の1%」だけを索引して小さく速く
ジョブキューや注文ステータスのようなテーブルでは、検索対象は常に「未処理(pending)」のごく一部なのに、行の99%は処理済み——という偏りが生まれます。全行を索引する通常 index は、この99%のためにサイズと更新コストを払い続けるムダを抱えます。CREATE INDEX ... WHERE 条件 の部分インデックスは、条件に合う行だけを索引する小さな index です。
-- pending 行(全体の1%)だけを索引する部分 index CREATE INDEX idx_tasks_pending ON tasks (created_at) WHERE status = 'pending'; -- ← この述語が index の「守備範囲」
WHERE status = 'pending' を書けば使われ、WHERE status IN ('pending', 'done') では使われません。「index の述語 ⊇ クエリの絞り込み」が成立するかをプランナが判定します。tasks テーブル(実体100万行、うち status = 'pending' は約1%)に対し、(1) pending 行だけを created_at 順に索引する部分インデックスを作成し、(2) それを使って最も古い pending タスク3件を取得してください。出力列は task_id, created_at, title。
| task_id | status | created_at | title |
|---|---|---|---|
| 1 | done | 2026-05-01 | 請求書作成 |
| 2 | pending | 2026-05-03 | 在庫棚卸 |
| 3 | done | 2026-05-04 | 月次レポート |
| 4 | pending | 2026-05-06 | 契約書レビュー |
| 5 | canceled | 2026-05-08 | 旧サイト改修 |
| 6 | pending | 2026-05-10 | 監査資料準備 |
| 7 | done | 2026-05-12 | 採用面接調整 |
| 8 | pending | 2026-05-13 | 価格改定通知 |
| task_id | created_at | title |
|---|---|---|
| 2 | 2026-05-03 | 在庫棚卸 |
| 4 | 2026-05-06 | 契約書レビュー |
| 6 | 2026-05-10 | 監査資料準備 |