Extensions
- pg_plan_guard 1.0.0
- Watch query plans for drift against captured baselines
README
Contents
pg_plan_guard
Detect when a query plan drifts away from the plan you approved.
PostgreSQL 19 added pg_plan_advice
(generate advice for a plan, then force it) and
pg_stash_advice (store
advice per query_id and apply it automatically). Both are deliberate, manual
acts: you decide a plan is good, and you pin it.
Neither of them watches. There is no way to ask:
Is the plan for this query still the plan I approved?
pg_plan_guard answers that question.
Why it matters
A plan regression usually does not fail. The query still returns the same rows — it just stops using the index and starts scanning. Nothing errors, nothing logs, nothing alerts.
The case this was built for: a vector similarity search backed by a DiskANN index over ~40,000 embeddings. If the planner stops choosing that index, the results are identical and still correctly ordered. It is simply a sequential scan now. Correct, silent, and slow.
That class of failure is the expensive one precisely because it does not announce itself. You find out weeks later from a latency graph, if at all.
Usage
CREATE EXTENSION pg_plan_guard;
-- Approve today's plan for a critical query.
SELECT plan_guard.capture(
'semantic_search',
'SELECT id FROM docs ORDER BY embedding <=> ''[...]'' LIMIT 10',
'must use the diskann index');
-- Later, on a schedule: has anything drifted?
SELECT * FROM plan_guard.verify();
-- name | state | expected_advice | actual_advice
-- -----------------+---------+-------------------------------+---------------------
-- semantic_search | drifted | INDEX_SCAN(docs docs_emb_idx) | SEQ_SCAN(docs) ...
Run it from pg_cron, or from whatever already runs your checks:
SELECT cron.schedule('plan-guard', '37 */6 * * *',
$$SELECT * FROM plan_guard.verify()$$);
And point your monitoring at one view:
SELECT * FROM plan_guard.status WHERE state <> 'ok';
API
| Function | Purpose |
|---|---|
plan_guard.capture(name, query_sql [, description]) |
Approve the current plan as the baseline |
plan_guard.verify([name]) |
Re-plan every baseline and report drift |
plan_guard.sync_stash(stash_name) |
Push approved advice into a pg_stash_advice stash |
plan_guard.advice_for(query_sql) |
Plan advice for an arbitrary query |
plan_guard.query_id_for(query_sql) |
query_id of a query, for stash operations |
| Relation | Contents |
|---|---|
plan_guard.baselines |
The approved plan for each query |
plan_guard.drift_log |
Append-only history of every detected drift |
plan_guard.status |
Current state, worst first — the view a monitor polls |
Design decisions
These are the choices that make it usable rather than annoying, and the reasons behind them:
Advice is compared, not EXPLAIN output. EXPLAIN text changes with row
estimates and costs even when the plan shape is identical. Comparing it would
make baselines drift constantly and train everyone to ignore the alerts. Advice
describes the shape — which scan on which relation, which join order, which
method — so it changes only when the planner’s decision changes.
Capture is explicit, never automatic. A baseline that captured itself would happily bless whatever plan happened to be in effect, including the regression you are hunting.
Drift is logged once per transition, not once per check. A baseline that has been drifting for a week should not produce a row per cron run.
A broken baseline does not abort the run. If a query no longer plans (table
dropped, column renamed) it is reported as error and the remaining baselines
are still checked. A monitor that dies on the first problem stops working
exactly when something is wrong.
The table is the source of truth, not shared memory. pg_stash_advice
persists across restarts, but if persistence ever fails or the cluster is
recreated, the pinning disappears silently — queries keep working, just slowly.
That is the same failure mode this extension exists to catch, so the stash is
treated as a cache that can always be rebuilt from plan_guard.baselines via
sync_stash().
Requirements
- PostgreSQL 19 or later, with
pg_plan_adviceavailable. sync_stash()additionally requirespg_stash_advice, which needs two things to actually apply advice — and if either is missing, nothing is applied and nothing warns you:shared_preload_librariesincludespg_plan_adviceandpg_stash_advicepg_stash_advice.stash_nameis set (the default is empty)
Diagnose with:
sql SELECT name, setting FROM pg_settings WHERE name LIKE 'pg_stash%';
Baselines are captured with EXPLAIN (PLAN_ADVICE), which does not execute
the query. Verification is therefore cheap and safe to schedule.
Install
make install PG_CONFIG=/path/to/pg_config
psql -c 'CREATE EXTENSION pg_plan_guard'
Run the tests against a live server:
make installcheck PG_CONFIG=/path/to/pg_config
Limitations
- Queries are stored as text and re-planned as written. Parameterized queries
must be captured in an executable form (literal values), since
EXPLAINneeds a complete statement. - Drift detection is only as good as the baseline: capturing a bad plan pins a
bad plan. Review what
capture()returns. capture()andverify()runEXPLAINon stored SQL, so execute rights are not granted toPUBLIC.
License
PostgreSQL License. See LICENSE.