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 COLUMNorDROP COLUMNwhile the app has active connections - Using
CREATE OR REPLACE VIEWwith 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.

