Fix PostgreSQL 'cached plan must not change result type' After Schema Changes

intermediate๐Ÿ˜ PostgreSQL2026-07-04| PostgreSQL 12โ€“16, any OS (Linux/macOS/Windows), common with PgBouncer, SQLAlchemy, Django, Rails, or any app using prepared statements

Error Message

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

The Error

ERROR: cached plan must not change result type

You just ran a migration โ€” added a column, changed a type, replaced a view โ€” and now your app is throwing this on every request. Logs filling up, users hitting 500s, and nothing in the migration itself looks wrong.

Here's why it happens: PostgreSQL caches query execution plans for prepared statements. When your schema changed, the cached plan no longer matched what the query would return. Rather than silently hand back wrong data, PostgreSQL refuses to run it. That's actually correct behavior โ€” it just lands at the worst possible moment.

What Triggers This

  • Running ALTER TABLE ... ADD COLUMN or DROP COLUMN while the app has active connections
  • Using CREATE OR REPLACE VIEW with a different column list or type
  • Changing a column's data type with ALTER COLUMN ... TYPE
  • Using SELECT * in a prepared statement โ€” any new column changes the result shape
  • PgBouncer in session mode reusing connections across requests

Check What's Currently Cached

Start by seeing which prepared statements are active on the database:

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

Running PgBouncer? This query returns nothing โ€” the pooler owns the backend connections, so cached plans live on its side, not visible here. Skip straight to the PgBouncer section below.

Fix 1: Restart the Application (Fastest)

At 2 AM during an incident, this is your move. Restart your app processes โ€” every connection closes, all cached plans are wiped, and new connections start fresh against the updated schema.

# systemd
sudo systemctl restart your-app

# Docker
docker compose restart app

# PM2
pm2 restart all

Blunt, but it works. Fix the root cause once things have calmed down.

Fix 2: Deallocate Plans Without Restarting

Can't afford downtime? You can flush cached plans from the database side without touching the app.

Connect as a superuser and run this for each affected connection:

DEALLOCATE ALL;

Or target a specific plan by name:

DEALLOCATE my_plan_name;

The catch: DEALLOCATE ALL only clears the current session. With a production pool of 20โ€“50 connections, you'd have to run it in every single one. Not practical.

A more surgical approach โ€” terminate all app connections and let them reconnect automatically:

-- Find connections from your app
SELECT pid, usename, application_name, state
FROM pg_stat_activity
WHERE application_name = 'your-app-name';

-- Terminate them (they'll reconnect on next request)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE application_name = 'your-app-name'
  AND pid != pg_backend_pid();

Fix 3: PgBouncer โ€” The Common Culprit

PgBouncer in session mode is the most frequent source of this error in production. The pooler holds long-lived connections to PostgreSQL. Schema changes invalidate cached plans on those connections โ€” but PgBouncer keeps handing them out anyway.

Restart PgBouncer to drop all pooled connections at once:

sudo systemctl restart pgbouncer

# Or via the admin console:
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
RECONNECT mydb;

Already in transaction mode? Use DISCARD ALL, which PgBouncer intercepts and handles at the pool level:

DISCARD ALL;

For a permanent fix, switch to transaction mode and add server_reset_query = DISCARD ALL to pgbouncer.ini. This clears session state between connections automatically:

[pgbouncer]
pool_mode = transaction
server_reset_query = DISCARD ALL

Fix 4: Prevent It During Migrations

Better yet: prevent this race condition from happening in the first place.

Option A: Restart immediately after migrating

# 1. Run the migration
python manage.py migrate
# or
bundle exec rails db:migrate

# 2. Restart before traffic hits the new schema
sudo systemctl restart your-app

Option B: Drain old instances before schema changes land

For blue-green or rolling deployments, make sure old app instances finish their in-flight requests and disconnect before the migration runs. This is a deployment sequencing problem more than a PostgreSQL one.

Option C: Stop using SELECT * in prepared statements

Explicit column lists are immune to this error. Any column added to the table won't affect the result type the cached plan expects:

-- Risky: any new column breaks the cached plan
SELECT * FROM users WHERE id = $1;

-- Safe: schema additions don't affect result type
SELECT id, email, created_at FROM users WHERE id = $1;

In SQLAlchemy, watch for session.query(User) โ€” it defaults to SELECT * behavior. Switch to explicit columns with .with_entities(User.id, User.email). In Django, .values('id', 'email') does the same job for sensitive queries.

Verify the Fix

Check that stale plans are gone:

-- Should return 0 rows if you just deallocated
SELECT name, statement FROM pg_prepared_statements;

Then test the failing endpoint directly. If the error came from a specific query, reproduce it in psql to confirm it's clean:

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

If the Error Keeps Coming Back

Recurring without migrations? Two things to check. First, look for automated jobs running DDL โ€” CREATE OR REPLACE VIEW calls that quietly change column definitions are a common culprit. Second, check your pool's max connection age.

A pool that never recycles connections accumulates stale plans over time. Set a max lifetime in your pool config:

# SQLAlchemy example
engine = create_engine(
    DATABASE_URL,
    pool_recycle=3600,  # close and reopen connections after 1 hour
    pool_pre_ping=True,  # test connections before use, drop dead ones
)

pool_pre_ping=True is worth enabling regardless โ€” it catches dead connections before they reach application code, not after.

Related Error Notes