開発者が知っておくべきPostgreSQLパフォーマンス改善Tips 入門10選
更新(2026年2月4日)
- SQLではなくPostgreSQLの中身のはなし
更新(2026年1月30日)
- 入門編の次の初級編はこちら
対象読者
- 最近になってAIコーディングをはじめたひと
- 経験2年未満レベルのジュニアエンジニア
- ほか、そのへんのレベル感の方向け
はじめに
「開発環境では爆速だったのに、本番データが入った途端にタイムアウトする」
そんな「時限爆弾」を埋め込まないために、開発者が最低限知っておくべきPostgreSQLのパフォーマンス改善Tipsをまとめました。
PostgreSQLと銘打ってますが、別にそれ以外でも応用効くものばかりです。
高度なパラメータチューニングではなく、入門編です。日々のコード実装ですぐに実践できます。
【クエリの書き方・アンチパターン】
1. WHERE句の左辺(カラム)を加工しない
日付検索などで、以下のようなクエリを書いてしまうケースがあります。
-- NG: カラムに関数を適用している
SELECT * FROM orders WHERE DATE(created_at) = '2025-01-01';
直感的ですが、これはパフォーマンス的にNGです。
インデックスは「カラムの値そのもの」に対して作られているため、左辺のカラムに関数(この場合は DATE())を通すとインデックスが使われず、全件スキャン(Seq Scan)が発生します。
これを避けるには、左辺のカラムは加工せず、右辺で範囲を指定するのが鉄則です。
-- OK: 右辺で範囲を指定する
SELECT * FROM orders
WHERE created_at >= '2025-01-01'
AND created_at < '2025-01-02';
どうしても関数を使った検索が必要な場合は、式インデックス(Expression Index) や 生成列(Generated Columns) の利用を検討します。
2. SELECT * を手癖にしない
「とりあえず全カラム取得」は、不要なI/Oやメモリ消費を招くだけでなく、Index Only Scan の機会を損失します。
Index Only Scanとは、必要なデータがすべてインデックス内に存在する場合、テーブル本体へのアクセスをスキップして高速に応答する仕組みです。
IDとステータスだけが必要なら、そのように指定することで、パフォーマンスが劇的に向上する可能性があります。
3. COUNT(*) のコストを理解する
PostgreSQLは追記型アーキテクチャ(MVCC)を採用しているため、COUNT(*) を実行する際、インデックスがあっても「各行が現在のトランザクションで見えているか」を確認するためにテーブルをスキャンする必要があります。
そのため、データ量に比例して処理が重くなります。
-
概算で良い場合:
pg_class.reltuples(統計情報の推定値)を使用すれば一瞬で取得できます。 - 正確な値が必要な場合: アプリケーション側でカウンターテーブルを更新するか、トリガーを用いた集計管理を検討してください。
4. OFFSET ページネーションを避ける
Webサービスのページネーションでよく使われる OFFSET 10000 LIMIT 20 は、「先頭から1万件を読み込んで捨てる」処理を行います。そのため、ページ数が進むほど遅くなります。
Seek Method (Keyset Pagination) への移行を推奨します。
これは「前のページの最後のIDより大きいもの」を取得する方式です。
-- 前ページの最後のIDが 10000 だった場合
SELECT * FROM items
WHERE id > 10000
ORDER BY id ASC
LIMIT 20;
この方法であれば、何ページ目であってもインデックスを利用して高速に取得できます。
5. 存在確認に COUNT や IN を使わない
「データが存在するかどうか」だけを知りたい場合に、COUNT で件数を取得したり、IN でデータを取得するのは非効率です。
-- NG: 全件カウントしている
IF (SELECT COUNT(*) FROM users WHERE email = '...') > 0 THEN ...
-- OK: 1件見つかった時点で終了する
IF EXISTS (SELECT 1 FROM users WHERE email = '...') THEN ...
EXISTS は条件に合致する行が1つ見つかった時点でスキャンを停止するため、高速です。
【インデックス設計の勘所】
6. 外部キーには必ずインデックスを貼る
PostgreSQLでは、外部キー制約を定義しても自動でインデックスは作成されません。
JOINの高速化だけでなく、親テーブルの行を削除・更新する際、整合性チェックのために子テーブルがロック(またはフルスキャン)されるのを防ぐ ためにも重要です。
本番環境での予期せぬロック競合やデッドロックを防ぐため、外部キーには明示的にインデックスを作成しましょう。
7. 複合インデックスは「カラムの順序」を意識する
複数のカラムでインデックス(複合インデックス)を作成する場合、カラムの順序が重要です。
B-Treeインデックスは、左側のカラムから順に 使用されます。
インデックス (a, b) がある場合:
-
WHERE a = 1-> インデックスが効く -
WHERE b = 2-> インデックスが効かない(または効率が悪い)
検索条件として頻繁に使われるカラムや、絞り込み性能が高い(カーディナリティが高い)カラムを左側に配置しましょう。
8. NULLが多いカラムには部分インデックス
「論理削除フラグ」や「処理済み日時」のように、多くの行がNULL(または特定の値)で、少数の行だけを検索対象としたい場合は、部分インデックス(Partial Index) が有効です。
-- processed_at が NULL の行(未処理タスク)だけを高速に検索したい
CREATE INDEX idx_tasks_unprocessed ON tasks (created_at)
WHERE processed_at IS NULL;
WHERE 句を指定してインデックスを作成することで、インデックスサイズを削減し、更新時のオーバーヘッドも最小限に抑えられます。
【アプリ実装・運用】
9. N+1問題はログで早期発見する
N+1問題はコードレビューだけで全てを防ぐのは困難です。
開発環境で log_min_duration_statement を短く設定し、実行されるSQLログを確認する習慣をつけましょう。
「同じようなクエリがIDだけ変えて連続して実行されている」場合、それはN+1問題です。早期に検知し、アプリケーション側でEager Loading(includes や with など)を使って対処します。
10. 大量INSERTはバルクインサートで行う
ループ処理の中で1件ずつ INSERT を実行するのは、通信とトランザクションのオーバーヘッドが大きいため避けるべきです。
// NG
for (const item of items) {
await db.query("INSERT INTO ...");
}
可能な限り、INSERT INTO ... VALUES (...), (...), ... のように1回のクエリでまとめて挿入(バルクインサート)するか、大量データの場合は COPY コマンドの使用を検討してください。
おわりに
PostgreSQLはデフォルト設定でも堅牢なデータベースですが、その性能を引き出すには、アプリ側の「クエリの書き方」や「インデックスの使い所」への理解が不可欠です。
特に「SARGable(インデックスを効かせる書き方)」や「N+1の回避」は、後から修正するのが大変な場合も多いです。機能実装の段階で、これらのTipsを少しでも意識していただければ幸いです。
Discussion