PostgreSQL でインデックスが使われない 7 つの原因と対処法(EXPLAIN の出力つき)
インデックスを作成したのに EXPLAIN が Seq Scan のまま。PostgreSQL がインデックスを使わない 7 つの原因(関数、型キャスト、ワイルドカード、選択性、古い統計情報、カラム順、小さなテーブル)を、再現手順と対処法つきで解説します。
目次
CREATE INDEX を実行し、クエリも実行したのに、EXPLAIN は相変わらず Seq Scan と表示する。プランナーのせいにする前に知っておいてほしいことがあります。ほとんどの場合、プランナーは「書かれたこと」については正しく、間違っているのは「意図したこと」とのずれだけです。この記事では、PostgreSQL がインデックスを無視する原因のうち、特によく見かける 7 つを取り上げます。カラムへの関数適用、型の不一致、先頭のワイルドカード、テーブルの大半に一致するフィルター、古い統計情報、順序の誤ったマルチカラムインデックス、そして単純にテーブルが小さすぎるケースです。どのケースも PostgreSQL 17.11 での再現手順、実際に得られた実行計画、対処法を載せています。セットアップを psql に貼り付ければ、デフォルト設定で同じ実行計画になります。
クエリの前に 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 つのテーブルを使います。データは生成するため、ダウンロードするものはありません。
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;基準として、インデックスを設定したカラムへの完全一致では、インデックスが使われます。
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email = 'User42@Example.com'; Index Scan using users_email_idx on users
Index Cond: (email = 'User42@Example.com'::text)以降は、すべてこのクエリを少しだけ変えて、インデックスが使われなくなる例です。
email のインデックスには email の値が格納されています。lower(email) に対する条件は別の値についての問い合わせなので、インデックスでは答えられません。
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE lower(email) = 'user42@example.com'; Gather
Workers Planned: 1
-> Parallel Seq Scan on users
Filter: (lower(email) = 'user42@example.com'::text)実行計画は並列ですが、それでも順次スキャンです。対処法は式インデックス、つまりクエリが使うのと同じ式に対するインデックスです。
CREATE INDEX users_lower_email_idx ON users (lower(email));
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE lower(email) = 'user42@example.com'; 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' は使えません。
比較の両辺の型が異なる場合、Postgres はどちらかを変換する必要があります。カラムのほうが変換されると、そのカラムの通常のインデックスは適用できなくなります。ここでは id は bigint で、リテラルの 42.0 は numeric です。
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'; 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 で保存されていて数値と比較されているなら、カラムの型を変更するのが本当の対処法です。
B-tree インデックスはソートされているため、User4 で始まるものはすばやく見つけられます。一方、1234@Example.com で終わるものは見つけられません。値は末尾の順にはソートされていないからです。
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email LIKE '%1234@Example.com'; Seq Scan on users
Filter: (email ~~ '%1234@Example.com'::text)pg_trgm 拡張は 3 文字の断片をインデックスにします。これにより Postgres は、先頭にワイルドカードがあるパターン、ILIKE、% による類似検索でもインデックスを使えます。pg_trgm は Relyt One で利用できる拡張の 1 つです。インデックスは、演算子クラス gin_trgm_ops を指定した GIN インデックスです。
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'; 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)を使ってください。
インデックスが効果を発揮するのは、テーブルの大部分を読み飛ばせるときです。条件が行の 99% に一致するなら、インデックスを 198,000 回たどるより、テーブル全体を順に読むほうが安く済みます。プランナーはそれを分かっています。
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'; Seq Scan on users
Filter: (status = 'active'::text)
Index Scan using users_status_idx on users
Index Cond: (status = 'banned'::text)これはインデックスが設計どおりに働いている状態です。同じインデックスが、1% の行には使われ、99% の行では使われません。プランナー側で直すべきことはありません。一般的な値に対するクエリが重要なら、そもそもそれだけ多くの行を返すべきかどうかが問題です(LIMIT、追加のフィルター、集計を検討してください)。
改善できるのは、まれな値のほうです。部分インデックスは、検索する行だけを対象にするため、小さく、維持するコストも低く済みます。
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'; Index Only Scan using users_banned_idx on usersクエリの WHERE 句は、インデックスの WHERE 句を含意している必要があります。status = 'banned' を求めるクエリはこのインデックスを使えますが、別のステータスを求めるクエリは使えません。
プランナーはクエリを計画するとき、データそのものは見ません。ANALYZE が収集した統計情報を見て、そこから見積もります。最後の ANALYZE の後にテーブルが大きく変わると、見積もりが現実と合わなくなり、実行計画がどちらの方向にも誤ることがあります。インデックスを使うべきでない場面でインデックスを選ぶ場合も含まれます。
autovacuum は通常、テーブルの約 10% が変更された時点で統計情報を更新します(autovacuum_analyze_scale_factor = 0.1 に、基本値として 50 行が加わります)。効果を確実に再現するため、1 つのテーブルだけ autovacuum を無効にし、5 行の状態で ANALYZE してから、30 万行を一括ロードします。
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'; 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 の後は、同じクエリの実行計画が変わります。
ANALYZE events;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM events WHERE kind = 'view'; 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 より細かい情報をプランナーに与えられます。
マルチカラム B-tree インデックスは、最初のカラムでソートされ、最初のカラムの値ごとに 2 番目のカラムでソートされ、以降も同様です。先頭のカラムで絞り込むクエリはうまく使えますが、先頭のカラムを飛ばすクエリは使えません。
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'; 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 だけで絞り込む必要があるなら、それを先頭に置いたインデックスが必要です。両方の種類のクエリに対応するインデックスでは、等価条件のカラムを先に、範囲条件のカラムを最後に置くのが通常の規則です。
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'; 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 では使われません。スキップスキャンが有効なのは、先頭カラムの異なる値をほとんど飛ばさずに済むとプランナーが見込む場合か、後続の条件の選択性が高い場合(今回がそうでした)に限られます。そのため、クエリに合わせて設計したインデックスのほうが確実です。
また、インデックスはすべて、書き込みのたびにコストがかかります。追加する前に、既存のインデックスでそのクエリをすでにカバーできていないか確認してください。
20 行のテーブルは 1 ページに収まります。そのページを読むほうが、インデックスのページを読んでからテーブルのページを読むより安いため、プランナーはテーブルをスキャンします。
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; Seq Scan on plans
Filter: (id = 7)これは正しい動作ですが、小さな開発用データベースでテストして本番と同じ実行計画を期待したり、その逆をしたりすると、落とし穴になります。インデックスに関する判断は、サイズも分布も本番に近いデータでテストしてください。
インデックスが使えるのか、それとも単に選ばれていないだけなのか判断できないときのために、診断用のスイッチもあります。1 つのトランザクションでシーケンシャルスキャンを無効にして、実行計画を見ます。
BEGIN;
SET LOCAL enable_seqscan = off;
EXPLAIN (COSTS OFF) SELECT * FROM plans WHERE id = 7;
ROLLBACK; Index Scan using plans_pkey on plans
Index Cond: (id = 7)インデックスが現れたなら、それは使用可能で、プランナーがテーブルスキャンのほうが安いと判断したということです。統計情報、選択性、テーブルサイズを確認してください。現れないなら、書かれたとおりのクエリにはそのインデックスを使えないので、ケース 1、2、3、6 に戻ります。このスイッチは診断専用です。有効にしたままにしたり、アプリケーションコードに入れたりしないでください。
逆の問題は、どこからも使われていないインデックスです。これらはすべての挿入と更新を遅くし、容量も消費します。pg_stat_user_indexes は、統計情報が最後にリセットされてからのインデックススキャン回数を数えます。
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 回実行し、統計情報が報告されるまで少し待った後の出力は、次のとおりでした。
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 のインデックスは削除の候補であって、不要の証明ではありません。一意制約を保証するために存在しているのかもしれず、統計情報のリセット以降まだ実行されていない月次レポートで使われているのかもしれません。
EXPLAIN (ANALYZE, BUFFERS)を実行し、支配的なノードを見つけます。- その
Filter行で、カラムに適用された関数やキャストを探します(ケース 1 と 2)。 - パターンが
%で始まる場合は、トライグラムインデックスを検討します(ケース 3)。 rows=とactual rows=を比べます。大きな差があればANALYZEです(ケース 5)。- 条件に一致する行数を確認します。テーブルの大半であれば、そのスキャンは正しい動作です(ケース 4)。
- マルチカラムインデックスでは、クエリが先頭のカラムを絞り込んでいるか確認します(ケース 6)。
- 現実的なデータでテストし、
SET LOCAL enable_seqscan = offを使って「使えない」のか「選ばれていない」のかを見分けます(ケース 7)。
大きなテーブルでは、インデックスの構築そのものが高コストです。通常の CREATE INDEX は実行中に書き込みをブロックし、CREATE INDEX CONCURRENTLY はそれを避けられる代わりに構築が遅くなります。Relyt One では、すべてのブランチが独自のコンピュートとコピーオンライトのストレージを持っています。そのため、データベースのブランチを作成してそこでインデックスを構築し、実データに対して EXPLAIN (ANALYZE) を実行し、実行計画が改善しなければブランチを破棄できます。メインブランチはインデックスを構築することも、実験を目にすることもありません。比較するのは、ブランチと本番の実行計画と行数の見積もりであり、実行時間ではありません。ブランチのコンピュートはサイズが異なる場合があり、キャッシュが空の状態で始まるからです。
タグPostgresindexesperformanceexplain



