AI Features and extensions pgvector
David Sterling  

pg_plan_filter 1.0: Block Runaway Postgres Queries by Cost

Somewhere in your cluster there is a query that runs fine for eight months and then takes production down: an analytics scan with no WHERE clause, an ORM that forgot its tenant filter, a support agent asking a language model to “show me everything about this account.” By the time statement_timeout fires, most of the damage is done. The query has already read a good chunk of the heap, evicted your working set, and held a connection while every other session queues behind it.

On October 7, 2026, PG Experts (PGX, Inc.) released pg_plan_filter 1.0.0, a loadable module that refuses statements whose estimated plan cost exceeds a limit you configure — before the executor starts. The 1.0 release adds a per-transaction budget on top of the per-statement one, so a batch interface can no longer stack thousands of individually cheap statements into one unbounded transaction. The module is the work of Andrew Dunstan and the PG Experts team, originally sponsored by Twitch, and it is open source on GitHub.

What it refuses

All settings live under the reserved plan_filter. prefix, and all of them default to off, so the module does nothing until you set a limit:

  • plan_filter.statement_cost_limit — refuses any single statement whose finished plan has an estimated total cost above the limit.
  • plan_filter.transaction_cost_limit — caps the combined estimated cost of all statements run inside one transaction.
  • plan_filter.filter_select_only — defaults to false; set it to true to apply the limits to SELECT only.

Note the default: the filter covers every statement type, which matters because SELECT is not read-only — an INSERT ... SELECT or a SELECT calling a volatile function can write freely. Restricting the filter to SELECT is a convenience for clusters that want to leave ETL untouched, not a safer default.

Install: a module, not an extension

There is no .control file, which means no CREATE EXTENSION plan_filter and no PGXN package. You build it against the same headers as your server and load it from postgresql.conf:

export PATH=/usr/lib/postgresql/18/bin:$PATH
make && sudo make install

# postgresql.conf
shared_preload_libraries = 'plan_filter'
plan_filter.statement_cost_limit = 100000.0
plan_filter.transaction_cost_limit  = 500000.0

Because it goes in shared_preload_libraries, it needs a restart, not a reload. The LOAD statement also works but the project recommends it for development only. Version 1.0.0 supports PostgreSQL 14 through 18, requires at least 14 to build, and exercises every supported major in CI on each pull request. If your roadmap already includes PostgreSQL 19 beta, plan on waiting for that line to widen.

Picking a number without guessing

Planner cost is not milliseconds. A cost of 100,000 might be a four-second report on a warm cache and a four-minute one on a cold one, so calibrate from plans you already run: find where your legitimately heavy reports land, find where the accidents land, and put the limit in the gap. Then confirm the guard is live and test the rejection path deliberately, remembering that the limit settings are superuser-only (SUSET):

SELECT current_setting('plan_filter.module_loaded', true) IS NOT NULL
       AS plan_filter_active;

SET plan_filter.statement_cost_limit = 10000;
SELECT count(*) FROM events;
-- ERROR:  plan cost limit exceeded
-- SQLSTATE 54001 (statement_too_complex)

That superuser requirement creates a wrinkle worth knowing before you turn the module on in anger: with a non-zero statement_cost_limit, pg_plan_filter refuses EXPLAIN on expensive queries too. You cannot inspect the plan you are not allowed to run. The documented escape hatch is a short transaction that lowers the limit to zero locally:

BEGIN;
SET LOCAL plan_filter.statement_cost_limit = 0;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
ROLLBACK;

The batch case: transaction_cost_limit

Batch interfaces fail differently from ad-hoc queries. Each statement is cheap, so a per-statement limit never fires, but ten thousand of them in one transaction is a lot of work with a lot of locks. transaction_cost_limit tracks the running total and raises the same error class once the next statement would push it over. The accounting rules are in the README and worth reading closely:

  • Cost is charged per execution, so repeatedly executing a prepared statement counts every time, even after PostgreSQL switches to a cached generic plan.
  • Plain EXPLAIN neither consumes budget nor gets refused once the budget is exhausted, so plans stay inspectable.
  • ROLLBACK TO SAVEPOINT does not refund cost already charged — the limit guards attempted resource consumption, not net effect.
  • Statements that execute no plan, such as most DDL, are not counted. Utility statements that wrap a query are: CREATE TABLE ... AS, CREATE MATERIALIZED VIEW, COPY (SELECT ...) TO.

Per-role limits are what make it usable

One ceiling for the whole cluster means either your nightly backfill breaks or you set the limit so high nothing is guarded. Set it per role instead:

ALTER ROLE app_rw        SET plan_filter.statement_cost_limit = 20000;
ALTER ROLE reporting_ro  SET plan_filter.statement_cost_limit = 250000;
ALTER ROLE batch_worker  SET plan_filter.transaction_cost_limit = 2000000;
ALTER ROLE dba_explain   SET plan_filter.statement_cost_limit = 0;

That pattern maps cleanly onto how Houston teams actually run Postgres. An energy trading platform can keep its intraday settlement job on a wide transaction budget, cap the customer-facing dashboards tightly, and leave analysts alone on a read replica. A healthcare shop serving a pgvector index over clinical documents can cap the retrieval service role hard, because a RAG query with a sane LIMIT has a modest estimated cost while the accidental join between embeddings and the full note table does not. When a rejected statement does come back, the error carries SQLSTATE 54001, so the application layer can map it to an HTTP 429 or a friendly “narrow your search” instead of leaking a database error.

What it does not protect you from

The project is refreshingly blunt about two limits. First, estimated cost and real runtime diverge: the planner will happily cost a nested loop over a huge table at almost nothing and then run for ten minutes. Expect false positives, and set the limits generously. Second, and more importantly, the estimate being compared is computed from planner cost settings — seq_page_cost, cpu_tuple_cost, parallelism costs — and every one of those is USERSET. Any role that can run SET can deflate its own estimated costs below any limit; the limit GUCs being superuser-only does not close that gap, because the number being compared is the one that moves.

Treat pg_plan_filter as a cooperative guard against accidental or careless load — a misconfigured ORM, a runaway report, an agent-written query — not as a security boundary against a hostile role. For batch throttling, keep it one layer behind an application-level limit. And keep the wall-clock backstops in place: statement_timeout, idle_in_transaction_session_timeout, a sane work_mem, analytics on a replica, and pg_stat_statements for visibility into what your real cost distribution looks like.

Rollout checklist

  1. Build and install the module, add it to shared_preload_libraries, restart, and verify plan_filter.module_loaded reads on.
  2. Mine pg_stat_statements and auto_explain for representative plan costs before choosing limits.
  3. Turn on statement_cost_limit cluster-wide at a generous value, and alert on SQLSTATE 54001 in application logs.
  4. Add transaction_cost_limit for ORM and batch roles, where the per-statement limit never fires.
  5. Give roles that legitimately need expensive work an explicit ALTER ROLE override, and document the SET LOCAL plus EXPLAIN workaround for whoever is on call.
  6. Keep statement_timeout and a read replica as the backstop the planner estimate cannot provide.

A guardrail that refuses a query by its plan cost is not clever, and it is not a security control. It is the kind of boring, pre-emptive check that keeps a bad Tuesday afternoon from becoming an incident — which is exactly why it belongs in postgresql.conf next to shared_preload_libraries.

Leave A Comment