🐘

開発者が知っておくべきPostgreSQLパフォーマンス改善Tips 入門10選

に公開

更新(2026年2月4日)

  • SQLではなくPostgreSQLの中身のはなし

https://zenn.dev/gizmo/articles/ef97eeb85238a8

更新(2026年1月30日)

  • 入門編の次の初級編はこちら

https://zenn.dev/gizmo/articles/5a3b81b56309c6

対象読者

  • 最近になって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(統計情報の推定値)を使用すれば一瞬で取得できます。
  • 正確な値が必要な場合: アプリケーション側でカウンターテーブルを更新するか、トリガーを用いた集計管理を検討してください。

参考: PostgreSQL Wiki: Slow Counting

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 句を指定してインデックスを作成することで、インデックスサイズを削減し、更新時のオーバーヘッドも最小限に抑えられます。

参考: PostgreSQL文書: 部分インデックス


【アプリ実装・運用】

9. N+1問題はログで早期発見する

N+1問題はコードレビューだけで全てを防ぐのは困難です。
開発環境で log_min_duration_statement を短く設定し、実行されるSQLログを確認する習慣をつけましょう。

「同じようなクエリがIDだけ変えて連続して実行されている」場合、それはN+1問題です。早期に検知し、アプリケーション側でEager Loading(includeswith など)を使って対処します。

10. 大量INSERTはバルクインサートで行う

ループ処理の中で1件ずつ INSERT を実行するのは、通信とトランザクションのオーバーヘッドが大きいため避けるべきです。

// NG
for (const item of items) {
  await db.query("INSERT INTO ...");
}

可能な限り、INSERT INTO ... VALUES (...), (...), ... のように1回のクエリでまとめて挿入(バルクインサート)するか、大量データの場合は COPY コマンドの使用を検討してください。

参考: PostgreSQL文書: POPULATING A DATABASE


おわりに

PostgreSQLはデフォルト設定でも堅牢なデータベースですが、その性能を引き出すには、アプリ側の「クエリの書き方」や「インデックスの使い所」への理解が不可欠です。

特に「SARGable(インデックスを効かせる書き方)」や「N+1の回避」は、後から修正するのが大変な場合も多いです。機能実装の段階で、これらのTipsを少しでも意識していただければ幸いです。

営業製作所株式会社

Discussion