# PostgreSQL でインデックスが使われない 7 つの原因と対処法（EXPLAIN の出力つき）

インデックスを作成したのに EXPLAIN が Seq Scan のまま。PostgreSQL がインデックスを使わない 7 つの原因（関数、型キャスト、ワイルドカード、選択性、古い統計情報、カラム順、小さなテーブル）を、再現手順と対処法つきで解説します。

**`CREATE INDEX` を実行し、クエリも実行したのに、`EXPLAIN` は相変わらず `Seq Scan` と表示する。プランナーのせいにする前に知っておいてほしいことがあります。ほとんどの場合、プランナーは「書かれたこと」については正しく、間違っているのは「意図したこと」とのずれだけです。この記事では、PostgreSQL がインデックスを無視する原因のうち、特によく見かける 7 つを取り上げます。カラムへの関数適用、型の不一致、先頭のワイルドカード、テーブルの大半に一致するフィルター、古い統計情報、順序の誤ったマルチカラムインデックス、そして単純にテーブルが小さすぎるケースです。どのケースも PostgreSQL 17.11 での再現手順、実際に得られた実行計画、対処法を載せています。セットアップを `psql` に貼り付ければ、デフォルト設定で同じ実行計画になります。**

## Postgres がインデックスを使っているかどうかは、どうすれば分かりますか？

クエリの前に `EXPLAIN` を付けます。実行計画のノードが `Index Scan`、`Index Only Scan`、`Bitmap Index Scan` のいずれかであれば、インデックスが使われています。`Seq Scan`（または `Parallel Seq Scan`）は、プランナーがテーブル全体を読むことを意味します。`EXPLAIN` はクエリの計画を立てるだけです。`EXPLAIN ANALYZE` は実際にクエリを実行し、実際の行数も表示します。後述の古い統計情報のケースでは、この実際の行数が必要になります。

次の 2 つの習慣を身につけると、実行計画が読みやすく、比較しやすくなります。

- `EXPLAIN (COSTS OFF)` はコストの数値を表示しません。コストの数値はテーブルのサイズや設定によって変わります。
- `EXPLAIN (ANALYZE, BUFFERS)` は、プランナーが見込んだページ数ではなく、クエリが実際にアクセスしたページ数を表示します。

## セットアップ

7 つのケースはすべて、20 万件のユーザーを持つ 1 つのテーブルを使います。データは生成するため、ダウンロードするものはありません。

```sql
SET TimeZone = 'UTC';
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE users (
  id         bigint PRIMARY KEY,
  email      text NOT NULL,
  status     text NOT NULL,
  org_id     int  NOT NULL,
  created_at timestamptz NOT NULL
);

INSERT INTO users
SELECT g,
       'User' || g || '@Example.com',
       CASE WHEN g % 100 = 0 THEN 'banned' ELSE 'active' END,
       (g % 500) + 1,
       timestamptz '2026-01-01' + (g || ' seconds')::interval
FROM generate_series(1, 200000) g;

CREATE INDEX users_email_idx ON users (email);
ANALYZE users;
```

基準として、インデックスを設定したカラムへの完全一致では、インデックスが使われます。

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email = 'User42@Example.com';
```

```text
 Index Scan using users_email_idx on users
   Index Cond: (email = 'User42@Example.com'::text)
```

以降は、すべてこのクエリを少しだけ変えて、インデックスが使われなくなる例です。

## 1. カラムを関数で包んでいる

`email` のインデックスには `email` の値が格納されています。`lower(email)` に対する条件は別の値についての問い合わせなので、インデックスでは答えられません。

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE lower(email) = 'user42@example.com';
```

```text
 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: (lower(email) = 'user42@example.com'::text)
```

実行計画は並列ですが、それでも順次スキャンです。対処法は**式インデックス**、つまりクエリが使うのと同じ式に対するインデックスです。

```sql
CREATE INDEX users_lower_email_idx ON users (lower(email));
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE lower(email) = 'user42@example.com';
```

```text
 Bitmap Heap Scan on users
   Recheck Cond: (lower(email) = 'user42@example.com'::text)
   ->  Bitmap Index Scan on users_lower_email_idx
         Index Cond: (lower(email) = 'user42@example.com'::text)
```

クエリの式とインデックスの式は一致している必要があります。`lower(email)` と `lower(trim(email))` は別の式です。同じ規則は `coalesce(col, 0)`、`price * 100` のような算術演算、日付関数にも当てはまります。日付関数にはもう 1 つ落とし穴があります。`timestamptz` カラムでは、`date_trunc('day', created_at)` や `created_at::date` はセッションのタイムゾーンに依存するため、Postgres はそれらを直接インデックスにすることを許可しません（`functions in index expression must be marked IMMUTABLE`）。代わりに `((created_at AT TIME ZONE 'UTC')::date)` のようにタイムゾーンを固定した式をインデックスにし、クエリでも同じ式を使ってください。

可能であれば、条件のほうを書き換えましょう。`created_at >= '2026-01-02' AND created_at < '2026-01-03'` は `created_at` の通常のインデックスを使えますが、`created_at::date = '2026-01-02'` は使えません。

## 2. 型が一致していない

比較の両辺の型が異なる場合、Postgres はどちらかを変換する必要があります。カラムのほうが変換されると、そのカラムの通常のインデックスは適用できなくなります。ここでは `id` は `bigint` で、リテラルの `42.0` は `numeric` です。

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id = 42;
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id = 42.0;
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id::text = '42';
```

```text
 Index Scan using users_pkey on users
   Index Cond: (id = 42)

 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: ((id)::numeric = 42.0)

 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: ((id)::text = '42'::text)
```

`Filter` 行を見てください。`(id)::numeric` は、カラムのほうがキャストされていることを示しています。このキャストが決め手であり、インデックスのあるカラムに対して実行計画が `Seq Scan` を示したときは、まずこれを探すべきです。

対処法は、リテラルやパラメーターをカラムの型に合わせることです。アプリケーションコードでよくある原因は、数値を `numeric` として送るドライバーや、文字列として届く値です。クエリの中でパラメーターをキャストする（`WHERE id = $1::bigint`）か、大元で型を修正してください。カラム自体の型が間違っている場合、たとえば識別子が `text` で保存されていて数値と比較されているなら、カラムの型を変更するのが本当の対処法です。

## 3. パターンがワイルドカードで始まっている

B-tree インデックスはソートされているため、`User4` で始まるものはすばやく見つけられます。一方、`1234@Example.com` で終わるものは見つけられません。値は末尾の順にはソートされていないからです。

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email LIKE '%1234@Example.com';
```

```text
 Seq Scan on users
   Filter: (email ~~ '%1234@Example.com'::text)
```

`pg_trgm` 拡張は 3 文字の断片をインデックスにします。これにより Postgres は、先頭にワイルドカードがあるパターン、`ILIKE`、`%` による類似検索でもインデックスを使えます。`pg_trgm` は Relyt One で利用できる拡張の 1 つです。インデックスは、演算子クラス `gin_trgm_ops` を指定した GIN インデックスです。

```sql
CREATE INDEX users_email_trgm_idx ON users USING gin (email gin_trgm_ops);
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email LIKE '%1234@Example.com';
```

```text
 Bitmap Heap Scan on users
   Recheck Cond: (email ~~ '%1234@Example.com'::text)
   ->  Bitmap Index Scan on users_email_trgm_idx
         Index Cond: (email ~~ '%1234@Example.com'::text)
```

トライグラムインデックスは B-tree インデックスより大きく、更新も遅くなります。効果が大きいのは、検索文字列が完全なトライグラムを少なくとも 1 つ含む長さの場合です。3 文字未満のパターンでは、ほとんど手がかりになりません。本当にやりたいことがテキスト中の単語検索であれば、`LIKE` ではなく全文検索（`tsvector`）を使ってください。

## 4. フィルターがテーブルの大半に一致している

インデックスが効果を発揮するのは、テーブルの大部分を読み飛ばせるときです。条件が行の 99% に一致するなら、インデックスを 198,000 回たどるより、テーブル全体を順に読むほうが安く済みます。プランナーはそれを分かっています。

```sql
CREATE INDEX users_status_idx ON users (status);
ANALYZE users;

EXPLAIN (COSTS OFF) SELECT * FROM users WHERE status = 'active';
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE status = 'banned';
```

```text
 Seq Scan on users
   Filter: (status = 'active'::text)

 Index Scan using users_status_idx on users
   Index Cond: (status = 'banned'::text)
```

これはインデックスが設計どおりに働いている状態です。同じインデックスが、1% の行には使われ、99% の行では使われません。プランナー側で直すべきことはありません。一般的な値に対するクエリが重要なら、そもそもそれだけ多くの行を返すべきかどうかが問題です（`LIMIT`、追加のフィルター、集計を検討してください）。

改善できるのは、まれな値のほうです。**部分インデックス**は、検索する行だけを対象にするため、小さく、維持するコストも低く済みます。

```sql
DROP INDEX users_status_idx;
CREATE INDEX users_banned_idx ON users (id) WHERE status = 'banned';
EXPLAIN (COSTS OFF) SELECT id FROM users WHERE status = 'banned';
```

```text
 Index Only Scan using users_banned_idx on users
```

クエリの `WHERE` 句は、インデックスの `WHERE` 句を含意している必要があります。`status = 'banned'` を求めるクエリはこのインデックスを使えますが、別のステータスを求めるクエリは使えません。

## 5. 統計情報が古い

プランナーはクエリを計画するとき、データそのものは見ません。`ANALYZE` が収集した統計情報を見て、そこから見積もります。最後の `ANALYZE` の後にテーブルが大きく変わると、見積もりが現実と合わなくなり、実行計画がどちらの方向にも誤ることがあります。インデックスを使うべきでない場面でインデックスを選ぶ場合も含まれます。

autovacuum は通常、テーブルの約 10% が変更された時点で統計情報を更新します（`autovacuum_analyze_scale_factor = 0.1` に、基本値として 50 行が加わります）。効果を確実に再現するため、1 つのテーブルだけ autovacuum を無効にし、5 行の状態で ANALYZE してから、30 万行を一括ロードします。

```sql
CREATE TABLE events (id bigint, kind text, created_at timestamptz);
ALTER TABLE events SET (autovacuum_enabled = false);
CREATE INDEX events_kind_idx ON events (kind);

INSERT INTO events SELECT g, 'click', now() FROM generate_series(1, 5) g;
ANALYZE events;
INSERT INTO events SELECT g, 'view', now() FROM generate_series(1, 300000) g;

EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM events WHERE kind = 'view';
```

```text
 Aggregate  (cost=4.31..4.32 rows=1 width=8) (actual rows=1 loops=1)
   ->  Index Only Scan using events_kind_idx on events  (cost=0.29..4.30 rows=1 width=0) (actual rows=300000 loops=1)
         Index Cond: (kind = 'view'::text)
         Heap Fetches: 300000
```

プランナーは 1 行（`rows=1`）を見込みましたが、実際は 300,000 行（`actual rows=300000`）でした。テーブル全体を読むことになる検索に対してインデックスを選んでおり、`Heap Fetches: 300000` は、すべての行でテーブルへのアクセスも必要だったことを意味します。`ANALYZE` の後は、同じクエリの実行計画が変わります。

```sql
ANALYZE events;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM events WHERE kind = 'view';
```

```text
 Finalize Aggregate  (cost=5558.22..5558.23 rows=1 width=8) (actual rows=1 loops=1)
   ->  Gather  (cost=5558.11..5558.22 rows=1 width=8) (actual rows=2 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         ->  Partial Aggregate  (cost=4558.10..4558.11 rows=1 width=8) (actual rows=1 loops=2)
               ->  Parallel Seq Scan on events  (cost=0.00..4116.92 rows=176474 width=0) (actual rows=150000 loops=2)
                     Filter: (kind = 'view'::text)
                     Rows Removed by Filter: 2
```

コストの数値は実行のたびに少し変わるので、桁まで比べないでください。ここでの教訓は、実行計画の読み方にあります。**すべてのノードで `rows=` と `actual rows=` を比べましょう。** 両者が桁違いに食い違っているなら、問題はインデックスではなく見積もりです。一括ロードや大規模なバックフィルの後は `ANALYZE` を実行してください。autovacuum が追いついていないと疑われる場合は、`pg_stat_user_tables` の `last_autoanalyze` を確認します。値の分布に偏りのあるカラムでは、統計ターゲットを上げる（`ALTER TABLE … ALTER COLUMN … SET STATISTICS 500`）と、デフォルトの 100 より細かい情報をプランナーに与えられます。

## 6. マルチカラムインデックスの順序が誤っている

マルチカラム B-tree インデックスは、最初のカラムでソートされ、最初のカラムの値ごとに 2 番目のカラムでソートされ、以降も同様です。先頭のカラムで絞り込むクエリはうまく使えますが、先頭のカラムを飛ばすクエリは使えません。

```sql
CREATE INDEX users_org_created_idx ON users (org_id, created_at);
ANALYZE users;

-- 先頭のカラムを絞り込んでいる: インデックスが使われる
EXPLAIN (COSTS OFF)
SELECT * FROM users WHERE org_id = 7 AND created_at >= '2026-01-02';

-- 先頭のカラムがない: 使われない
EXPLAIN (COSTS OFF)
SELECT * FROM users
WHERE created_at >= '2026-01-02 00:00:00' AND created_at < '2026-01-02 00:05:00';
```

```text
 Bitmap Heap Scan on users
   Recheck Cond: ((org_id = 7) AND (created_at >= '2026-01-02 00:00:00+00'::timestamp with time zone))
   ->  Bitmap Index Scan on users_org_created_idx

 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: ((created_at >= '2026-01-02 00:00:00+00'::timestamp with time zone) AND (created_at < ...
```

`created_at` だけで絞り込む必要があるなら、それを先頭に置いたインデックスが必要です。両方の種類のクエリに対応するインデックスでは、等価条件のカラムを先に、範囲条件のカラムを最後に置くのが通常の規則です。

```sql
CREATE INDEX users_created_org_idx ON users (created_at, org_id);
EXPLAIN (COSTS OFF)
SELECT * FROM users
WHERE created_at >= '2026-01-02 00:00:00' AND created_at < '2026-01-02 00:05:00';
```

```text
 Index Scan using users_created_org_idx on users
   Index Cond: ((created_at >= '2026-01-02 00:00:00+00'::timestamp with time zone) AND (created_at < ...
```

**この点はバージョンに依存します。** PostgreSQL 18 では B-tree インデックスにスキップスキャンが追加され、先頭のカラムを省いたクエリでもインデックスを使えるケースがあります。同じテーブルと同じ `(org_id, created_at)` インデックスを PostgreSQL 18.6 で試したところ、`created_at` だけの範囲クエリでも `(org_id, created_at)` インデックスが使われました。Relyt One が動かしている PostgreSQL 17 では使われません。スキップスキャンが有効なのは、先頭カラムの異なる値をほとんど飛ばさずに済むとプランナーが見込む場合か、後続の条件の選択性が高い場合（今回がそうでした）に限られます。そのため、クエリに合わせて設計したインデックスのほうが確実です。

また、インデックスはすべて、書き込みのたびにコストがかかります。追加する前に、既存のインデックスでそのクエリをすでにカバーできていないか確認してください。

## 7. テーブルが小さい、またはインデックスの価値がない

20 行のテーブルは 1 ページに収まります。そのページを読むほうが、インデックスのページを読んでからテーブルのページを読むより安いため、プランナーはテーブルをスキャンします。

```sql
CREATE TABLE plans (id int PRIMARY KEY, name text);
INSERT INTO plans SELECT g, 'plan' || g FROM generate_series(1, 20) g;
ANALYZE plans;

EXPLAIN (COSTS OFF) SELECT * FROM plans WHERE id = 7;
```

```text
 Seq Scan on plans
   Filter: (id = 7)
```

これは正しい動作ですが、小さな開発用データベースでテストして本番と同じ実行計画を期待したり、その逆をしたりすると、落とし穴になります。インデックスに関する判断は、サイズも分布も本番に近いデータでテストしてください。

インデックスが*使える*のか、それとも単に*選ばれていない*だけなのか判断できないときのために、診断用のスイッチもあります。1 つのトランザクションでシーケンシャルスキャンを無効にして、実行計画を見ます。

```sql
BEGIN;
SET LOCAL enable_seqscan = off;
EXPLAIN (COSTS OFF) SELECT * FROM plans WHERE id = 7;
ROLLBACK;
```

```text
 Index Scan using plans_pkey on plans
   Index Cond: (id = 7)
```

インデックスが現れたなら、それは使用可能で、プランナーがテーブルスキャンのほうが安いと判断したということです。統計情報、選択性、テーブルサイズを確認してください。現れないなら、書かれたとおりのクエリにはそのインデックスを使えないので、ケース 1、2、3、6 に戻ります。このスイッチは診断専用です。有効にしたままにしたり、アプリケーションコードに入れたりしないでください。

## 自分のデータベースで一度も使われていないインデックスはどれですか？

逆の問題は、どこからも使われていないインデックスです。これらはすべての挿入と更新を遅くし、容量も消費します。`pg_stat_user_indexes` は、統計情報が最後にリセットされてからのインデックススキャン回数を数えます。

```sql
SELECT count(*) FROM users WHERE email = 'User42@Example.com';
SELECT count(*) FROM users WHERE email = 'User43@Example.com';

SELECT pg_sleep(1.5);  -- 統計情報は少し遅れて報告されます

SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE relname = 'users'
ORDER BY idx_scan DESC, indexrelname;
```

テストテーブルで `email` への完全一致クエリを 2 回実行し、統計情報が報告されるまで少し待った後の出力は、次のとおりでした。

```text
     indexrelname      | idx_scan
-----------------------+----------
 users_email_idx       |        2
 users_banned_idx      |        0
 users_created_org_idx |        0
 users_email_trgm_idx  |        0
 users_lower_email_idx |        0
 users_org_created_idx |        0
 users_pkey            |        0
```

注意点が 2 つあります。`EXPLAIN` だけではスキャンとして数えられないため、実際に実行されたものだけが表示されます。また、スキャン回数が 0 のインデックスは削除の候補であって、不要の証明ではありません。一意制約を保証するために存在しているのかもしれず、統計情報のリセット以降まだ実行されていない月次レポートで使われているのかもしれません。

## 次の遅いクエリのためのチェックリスト

1. `EXPLAIN (ANALYZE, BUFFERS)` を実行し、支配的なノードを見つけます。
2. その `Filter` 行で、カラムに適用された関数やキャストを探します（ケース 1 と 2）。
3. パターンが `%` で始まる場合は、トライグラムインデックスを検討します（ケース 3）。
4. `rows=` と `actual rows=` を比べます。大きな差があれば `ANALYZE` です（ケース 5）。
5. 条件に一致する行数を確認します。テーブルの大半であれば、そのスキャンは正しい動作です（ケース 4）。
6. マルチカラムインデックスでは、クエリが先頭のカラムを絞り込んでいるか確認します（ケース 6）。
7. 現実的なデータでテストし、`SET LOCAL enable_seqscan = off` を使って「使えない」のか「選ばれていない」のかを見分けます（ケース 7）。

## 本番に触れずにインデックスの変更をテストする

大きなテーブルでは、インデックスの構築そのものが高コストです。通常の `CREATE INDEX` は実行中に書き込みをブロックし、`CREATE INDEX CONCURRENTLY` はそれを避けられる代わりに構築が遅くなります。Relyt One では、すべてのブランチが独自のコンピュートとコピーオンライトのストレージを持っています。そのため、データベースのブランチを作成してそこでインデックスを構築し、実データに対して `EXPLAIN (ANALYZE)` を実行し、実行計画が改善しなければブランチを破棄できます。メインブランチはインデックスを構築することも、実験を目にすることもありません。比較するのは、ブランチと本番の実行計画と行数の見積もりであり、実行時間ではありません。ブランチのコンピュートはサイズが異なる場合があり、キャッシュが空の状態で始まるからです。

[無料で始める](/auth/signin?ref=blog-postgres-not-using-index)
