PostgreSQLの「cached plan must not change result type」をスキーマ変更後に修正する

intermediate🐘 PostgreSQL2026-07-04| PostgreSQL 12〜16、任意のOS(Linux/macOS/Windows)、PgBouncer、SQLAlchemy、Django、Rails、またはプリペアドステートメントを使用するアプリで発生しやすい

Error Message

ERROR: cached plan must not change result type
#postgresql#prepared-statement#schema-change#cached-plan#pgbouncer

エラーの内容

ERROR: cached plan must not change result type

マイグレーションを実行した直後 — カラムの追加、型の変更、ビューの差し替え — から、すべてのリクエストでこのエラーが発生している。ログは埋まり続け、ユーザーは500エラーに直面しているのに、マイグレーション自体には何も問題が見当たらない。

なぜこうなるのか:PostgreSQLはプリペアドステートメントのクエリ実行プランをキャッシュする。スキーマが変わると、キャッシュされたプランはクエリが返すべき結果と一致しなくなる。PostgreSQLは誤ったデータを黙って返すのではなく、実行を拒否する。これは正しい動作だ — ただ、最悪のタイミングで起きるだけで。

発生する原因

  • アプリがアクティブな接続を持っている状態で ALTER TABLE ... ADD COLUMN または DROP COLUMN を実行した
  • カラムリストや型が異なる CREATE OR REPLACE VIEW を使用した
  • ALTER COLUMN ... TYPE でカラムのデータ型を変更した
  • プリペアドステートメントで SELECT * を使っている — 新しいカラムが追加されると結果の形が変わる
  • PgBouncer がセッションモードでリクエスト間の接続を再利用している

現在キャッシュされている内容を確認する

まずデータベース上でアクティブなプリペアドステートメントを確認する:

SELECT name, statement, parameter_types
FROM pg_prepared_statements
ORDER BY name;

PgBouncer を使っている場合、このクエリは何も返さない — プーラーがバックエンド接続を管理しているため、キャッシュされたプランはプーラー側に存在し、ここからは見えない。以下の PgBouncer のセクションに直接進むこと。

修正方法1:アプリを再起動する(最速)

障害対応で深夜2時なら、これが最善手だ。アプリのプロセスを再起動すれば、すべての接続が閉じられ、キャッシュされたプランがすべて消去され、新しい接続は更新済みのスキーマに対してクリーンな状態で開始される。

# systemd
sudo systemctl restart your-app

# Docker
docker compose restart app

# PM2
pm2 restart all

手荒な方法だが、効果はある。状況が落ち着いてから根本原因を修正すればよい。

修正方法2:再起動せずにプランを解放する

ダウンタイムを許容できない場合、アプリに手を加えずにデータベース側からキャッシュされたプランをフラッシュできる。

スーパーユーザーで接続し、影響を受けている各接続に対して以下を実行する:

DEALLOCATE ALL;

特定のプランを名前で指定する場合:

DEALLOCATE my_plan_name;

問題点:DEALLOCATE ALL は現在のセッションしかクリアしない。本番環境で20〜50の接続プールがある場合、すべての接続で実行する必要があり、現実的ではない。

より外科的なアプローチとして、アプリの接続をすべて終了させ、自動的に再接続させる:

-- アプリからの接続を探す
SELECT pid, usename, application_name, state
FROM pg_stat_activity
WHERE application_name = 'your-app-name';

-- 接続を終了する(次のリクエスト時に自動再接続される)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE application_name = 'your-app-name'
  AND pid != pg_backend_pid();

修正方法3:PgBouncer — よくある原因

セッションモードの PgBouncer は、本番環境でこのエラーが発生する最も多い原因だ。プーラーは PostgreSQL に対して長期間の接続を保持する。スキーマ変更によってその接続上のキャッシュされたプランが無効になるが、PgBouncer はそのまま接続を払い出し続ける。

PgBouncer を再起動してプールされたすべての接続を一度に切断する:

sudo systemctl restart pgbouncer

# または管理コンソールから:
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
RECONNECT mydb;

すでにトランザクションモードを使っている場合は DISCARD ALL を使う。PgBouncer がこれをインターセプトしてプールレベルで処理する:

DISCARD ALL;

恒久的な修正としては、トランザクションモードに切り替え、pgbouncer.iniserver_reset_query = DISCARD ALL を追加する。これにより接続間のセッション状態が自動的にクリアされる:

[pgbouncer]
pool_mode = transaction
server_reset_query = DISCARD ALL

修正方法4:マイグレーション時に防止する

そもそもこの競合状態を最初から防ぐのがベストだ。

方法A:マイグレーション直後に再起動する

# 1. マイグレーションを実行する
python manage.py migrate
# または
bundle exec rails db:migrate

# 2. トラフィックが新しいスキーマに当たる前に再起動する
sudo systemctl restart your-app

方法B:スキーマ変更が適用される前に古いインスタンスをドレインする

ブルーグリーンやローリングデプロイの場合、マイグレーションが実行される前に古いアプリインスタンスが処理中のリクエストを完了させて切断するようにする。これは PostgreSQL の問題というよりも、デプロイ順序の問題だ。

方法C:プリペアドステートメントで SELECT * を使うのをやめる

明示的なカラムリストはこのエラーの影響を受けない。テーブルに新しいカラムが追加されても、キャッシュされたプランが期待する結果の型には影響しない:

-- 危険:新しいカラムが追加されるとキャッシュされたプランが壊れる
SELECT * FROM users WHERE id = $1;

-- 安全:スキーマへの追加は結果の型に影響しない
SELECT id, email, created_at FROM users WHERE id = $1;

SQLAlchemy では session.query(User) に注意 — デフォルトで SELECT * の動作になる。.with_entities(User.id, User.email) で明示的なカラムに切り替えること。Django では、デリケートなクエリに対して .values('id', 'email') が同じ役割を果たす。

修正を確認する

古いプランが消えていることを確認する:

-- DEALLOCATE 直後であれば0件が返るはず
SELECT name, statement FROM pg_prepared_statements;

次に、失敗しているエンドポイントを直接テストする。特定のクエリでエラーが発生していた場合、psql で再現してクリーンになっていることを確認する:

PREPARE test_plan AS SELECT id, email FROM users WHERE id = $1;
EXECUTE test_plan(1);
DEALLOCATE test_plan;

エラーが繰り返し発生する場合

マイグレーションなしで繰り返し発生する場合、確認すべき点が2つある。まず、DDL を実行している自動化ジョブを探す — カラム定義を静かに変えてしまう CREATE OR REPLACE VIEW の呼び出しがよくある原因だ。次に、プールの最大接続寿命を確認する。

接続をまったく再生成しないプールは、時間とともに古いプランを蓄積する。プールの設定で最大寿命を設定する:

# SQLAlchemy の例
engine = create_engine(
    DATABASE_URL,
    pool_recycle=3600,  # 1時間後に接続を閉じて再作成する
    pool_pre_ping=True,  # 使用前に接続をテストし、死んでいる接続を切り捨てる
)

pool_pre_ping=True は、これとは関係なく有効にする価値がある — 死んでいる接続をアプリのコードに到達してから検出するのではなく、その前に検出できる。

Related Error Notes